Поделиться через


Тестирование на производственной системе баз данных

На этой странице мы обсудим методы написания автоматических тестов, включающих систему базы данных, с которой приложение работает в рабочей среде. Существуют альтернативные подходы к тестированию, в которых система рабочей базы данных переключается на двойные тесты; Дополнительные сведения см. на странице обзора тестирования . Обратите внимание, что тестирование на базе данных, отличной от используемой в рабочей среде (например, Sqlite), не рассматривается здесь, так как другая база данных используется в качестве тестового двойника; этот подход рассматривается в тестировании без рабочей системы базы данных.

Основное препятствие при тестировании, которое включает в себя реальную базу данных, заключается в том, чтобы обеспечить правильную изоляцию тестов, чтобы тесты, выполняемые параллельно (или даже в последовательном режиме), не влияли друг на друга. Полный пример кода для приведенного ниже кода можно просмотреть здесь.

Подсказка

На этой странице показаны методы xUnit, но аналогичные понятия существуют в других платформах тестирования, включая NUnit.

Настройка системы базы данных

Большинство систем баз данных в настоящее время можно легко установить как в средах CI, так и на компьютерах разработчиков. Хотя с помощью стандартного механизма установить базу данных достаточно легко, готовые к использованию образы Docker доступны для большинства основных баз данных и могут сделать установку особенно легкой в процессе интеграции. Для среды разработчика GitHub Workspacesконтейнер разработки может настроить все необходимые службы и зависимости, включая базу данных. Хотя для этого требуется начальная инвестиция в настройку, после этого у вас есть рабочая тестовая среда и может сосредоточиться на более важных вещах.

В некоторых случаях базы данных имеют специальный выпуск или версию, которая может оказаться полезной для тестирования. При использовании SQL Server LocalDB можно использовать для локального выполнения тестов практически без какой-либо настройки, запуская экземпляр базы данных по мере необходимости и, возможно, экономя ресурсы на менее мощных компьютерах разработчиков. Однако LocalDB не без проблем:

  • Он не поддерживает все, что делает SQL Server Developer Edition .
  • Он доступен только в Windows.
  • Это может привести к задержке при первом тестовом запуске, так как служба запускается.

Как правило, мы рекомендуем устанавливать выпуск SQL Server Developer, а не LocalDB, так как он предоставляет полный набор функций SQL Server и, как правило, его очень легко установить.

При использовании облачной базы данных обычно целесообразно проводить тестирование на локальной версии базы данных, чтобы повысить скорость и снизить затраты. Например, при использовании SQL Azure в эксплуатации, можно провести тестирование на локально установленной SQL Server — они очень похожи. Тем не менее, рекомендуется сначала выполнить тесты с самим SQL Azure перед вводом в эксплуатацию. При использовании Azure Cosmos DB эмулятор Azure Cosmos DB является полезным инструментом как для разработки локально, так и для выполнения тестов.

Создание, добавление и управление тестовой базой данных

После установки базы данных вы будете готовы использовать ее в тестах. В большинстве простых случаев набор тестов имеет одну базу данных, общую между несколькими тестами в нескольких классах тестов, поэтому нам нужна некоторая логика, чтобы убедиться, что база данных создана и заполняется ровно один раз во время выполнения теста.

При использовании Xunit это можно сделать с помощью фикстуры класса, которая представляет базу данных и используется в нескольких тестовых запусках.

public class TestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTestSample;Trusted_Connection=True;ConnectRetryCount=0";

    private static readonly object _lock = new();
    private static bool _databaseInitialized;

    public TestDatabaseFixture()
    {
        lock (_lock)
        {
            if (!_databaseInitialized)
            {
                using (var context = CreateContext())
                {
                    context.Database.EnsureDeleted();
                    context.Database.EnsureCreated();

                    context.AddRange(
                        new Blog { Name = "Blog1", Url = "http://blog1.com" },
                        new Blog { Name = "Blog2", Url = "http://blog2.com" });
                    context.SaveChanges();
                }

                _databaseInitialized = true;
            }
        }
    }

    public BloggingContext CreateContext()
        => new BloggingContext(
            new DbContextOptionsBuilder<BloggingContext>()
                .UseSqlServer(ConnectionString)
                .Options);
}

При инстанцировании приведённой выше фикстуры используется EnsureDeleted() для удаления базы данных (если она существует из предыдущего запуска) и EnsureCreated() для её создания с помощью вашей последней конфигурации модели (см. документацию по этим API). После создания базы данных, средство заполняет её некоторыми данными, которые могут быть использованы нашими тестами. Стоит уделить время размышлениям о начальных данных, поскольку их изменение для нового теста впоследствии может привести к ошибкам в существующих тестах.

Чтобы использовать фикстуру в тестовом классе, просто реализуйте IClassFixture для вашего типа фикстуры, и xUnit вставит её в ваш конструктор.

public class BloggingControllerTest : IClassFixture<TestDatabaseFixture>
{
    public BloggingControllerTest(TestDatabaseFixture fixture)
        => Fixture = fixture;

    public TestDatabaseFixture Fixture { get; }

Теперь класс тестирования имеет Fixture свойство, которое можно использовать в тестах для создания полнофункционального экземпляра контекста.

[Fact]
public async Task GetBlog()
{
    using var context = Fixture.CreateContext();
    var controller = new BloggingController(context);

    var blog = (await controller.GetBlog("Blog2")).Value;

    Assert.Equal("http://blog2.com", blog.Url);
}

Наконец, возможно, вы заметили некоторую блокировку в логике создания светильника выше. Если фикстура используется только в одном тестовом классе, она гарантированно будет создана xUnit точно один раз. Обычно одна и та же фикстура базы данных используется в нескольких тестовых классах. xUnit предоставляет объекты коллекций, однако этот механизм препятствует выполнению тестовых классов параллельно, что важно для производительности тестирования. Чтобы безопасно управлять этим с помощью фикстуры класса xUnit, мы используем простую блокировку для создания и заполнения базы данных и используем статический флаг, чтобы убедиться, что это не нужно делать дважды.

Тесты, которые изменяют данные

В приведенном выше примере показан тест только для чтения, который является простым случаем из точки зрения изоляции теста: поскольку ничего не изменяется, вмешательство теста невозможно. В отличие от этого, тесты, которые изменяют данные, являются более проблемными, так как они могут вмешиваться друг в друга. Одним из распространенных способов изоляции тестов на запись является оборачивание теста в транзакцию и откат этой транзакции после завершения теста. Поскольку ничего не сохраняется в базе данных, другие тесты не видят никаких изменений, и избегается помеха.

Вот метод контроллера, который добавляет блог в нашу базу данных:

[HttpPost]
public async Task<ActionResult> AddBlog(string name, string url)
{
    _context.Blogs.Add(new Blog { Name = name, Url = url });
    await _context.SaveChangesAsync();

    return Ok();
}

Мы можем протестировать этот метод следующим образом:

[Fact]
public async Task AddBlog()
{
    using var context = Fixture.CreateContext();
    context.Database.BeginTransaction();

    var controller = new BloggingController(context);
    await controller.AddBlog("Blog3", "http://blog3.com");

    context.ChangeTracker.Clear();

    var blog = await context.Blogs.SingleAsync(b => b.Name == "Blog3");
    Assert.Equal("http://blog3.com", blog.Url);

}

Некоторые заметки о приведенном выше коде теста:

  • Мы начинаем транзакцию, чтобы убедиться, что приведенные ниже изменения не фиксируются в базе данных и не вмешиваются в другие тесты. Так как транзакция никогда не фиксируется, она неявно откатывается в конце теста, когда экземпляр контекста удаляется.
  • После внесения нужных обновлений мы очищаем средство отслеживания изменений экземпляра контекста с помощью ChangeTracker.Clear, чтобы убедиться, что ниже мы действительно загружаем блог из базы данных. Вместо этого можно использовать два экземпляра контекста, но мы должны убедиться, что одна и та же транзакция используется обоими экземплярами.
  • Возможно, вы даже хотите начать транзакцию в фикстуре CreateContext, чтобы тесты получали экземпляр контекста, уже находящийся в транзакции и готовый к обновлениям. Это может помочь предотвратить случаи, когда транзакция случайно забывается, что приводит к помехам в тестировании, которые могут быть трудными для отладки. Вы также можете разделить тесты только для чтения и тесты на запись по разным классам тестирования.

Тесты, которые явно управляют транзакциями

Существует одна окончательная категория тестов, которая представляет собой дополнительную трудность: тесты, которые изменяют данные, а также явно управляют транзакциями. Так как базы данных обычно не поддерживают вложенные транзакции, невозможно использовать транзакции для изоляции, как описано выше, так как они должны использоваться фактическим кодом продукта. Хотя эти тесты, как правило, являются более редкими, необходимо обрабатывать их особым образом: необходимо очистить базу данных до исходного состояния после каждого теста, и параллелизация должна быть отключена, чтобы эти тесты не вмешивались друг в друга.

Рассмотрим следующий метод контроллера в качестве примера:

[HttpPost]
public async Task<ActionResult> UpdateBlogUrl(string name, string url)
{
    // Note: it isn't usually necessary to start a transaction for updating. This is done here for illustration purposes only.
    await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable);

    var blog = await _context.Blogs.FirstOrDefaultAsync(b => b.Name == name);
    if (blog is null)
    {
        return NotFound();
    }

    blog.Url = url;
    await _context.SaveChangesAsync();

    await transaction.CommitAsync();
    return Ok();
}

Предположим, что по какой-то причине метод требует использования сериализуемой транзакции (обычно это не так). В результате мы не можем использовать транзакцию для обеспечения изоляции тестов. Так как тест фактически зафиксирует изменения в базе данных, мы определим другое средство с собственной отдельной отдельной базой данных, чтобы убедиться, что мы не вмешиваемся в другие тесты, уже показанные выше:

public class TransactionalTestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTransactionalTestSample;Trusted_Connection=True;ConnectRetryCount=0";

    public BloggingContext CreateContext()
        => new BloggingContext(
            new DbContextOptionsBuilder<BloggingContext>()
                .UseSqlServer(ConnectionString)
                .Options);

    public TransactionalTestDatabaseFixture()
    {
        using var context = CreateContext();
        context.Database.EnsureDeleted();
        context.Database.EnsureCreated();

        Cleanup();
    }

    public void Cleanup()
    {
        using var context = CreateContext();

        context.Blogs.RemoveRange(context.Blogs);

        context.AddRange(
            new Blog { Name = "Blog1", Url = "http://blog1.com" },
            new Blog { Name = "Blog2", Url = "http://blog2.com" });
        context.SaveChanges();
    }
}

Эта функция тестирования аналогична той, что использована выше, но в частности содержит метод Cleanup; мы будем вызывать его после каждого теста, чтобы убедиться, что база данных сбрасывается в исходное состояние.

Если это исправление будет использоваться только одним классом тестирования, мы можем ссылаться на него как на исправление класса, как описано выше. XUnit не параллелизирует тесты в одном классе (дополнительные сведения о коллекциях тестов и параллелизации в документации xUnit). Если мы, однако, хотим воспользоваться этим компонентом в нескольких классах, мы должны убедиться, что эти классы не выполняются параллельно, чтобы избежать помех. Для этого мы будем использовать это в качестве фикстуры коллекции xUnit, а не в качестве фикстуры класса.

Во-первых, мы определим коллекцию тестов, которая ссылается на нашу фикстуру и будет использоваться всеми классами транзакционных тестов, которым она требуется.

[CollectionDefinition("TransactionalTests")]
public class TransactionalTestsCollection : ICollectionFixture<TransactionalTestDatabaseFixture>
{
}

Теперь мы ссылаемся на коллекцию тестов в нашем тестовом классе и принимаем светильник в конструкторе, как и раньше:

[Collection("TransactionalTests")]
public class TransactionalBloggingControllerTest : IDisposable
{
    public TransactionalBloggingControllerTest(TransactionalTestDatabaseFixture fixture)
        => Fixture = fixture;

    public TransactionalTestDatabaseFixture Fixture { get; }

Наконец, мы делаем наш класс тестов удобным для удаления, с вызовом метода сборки Cleanup после каждого теста.

public void Dispose()
    => Fixture.Cleanup();

Обратите внимание, что так как xUnit только когда-либо создает экземпляр средства сбора один раз, нам не нужно использовать блокировку вокруг создания базы данных и заполнения, как мы сделали выше.

Полный пример кода для приведенного выше кода можно просмотреть здесь.

Подсказка

Если у вас есть несколько классов тестов с тестами, которые изменяют базу данных, вы все равно можете запускать их параллельно, используя разные фикстуры, каждая из которых ссылается на собственную базу данных. Создание и использование многих тестовых баз данных не проблематично и должно выполняться всякий раз, когда это полезно.

Эффективное создание базы данных

В приведенных выше примерах мы использовали EnsureDeleted() и EnsureCreated() перед выполнением тестов, чтобы убедиться, что у нас есть обновленная тестовая база данных up-to. Эти операции могут быть немного медленными в некоторых базах данных, что может быть проблемой при итерации изменений кода и повторном многократном выполнении тестов. Если это так, может потребоваться временно закомментировать EnsureDeleted в конструкторе светильника: это повторно будет использовать ту же базу данных во время тестового выполнения.

Недостатком этого подхода является то, что при изменении модели EF Core схема базы данных не будет актуальной, и тесты могут завершиться ошибкой. В результате мы рекомендуем выполнять это временно во время цикла разработки.

Эффективная очистка базы данных

Мы видели выше, что при фактическом фиксации изменений в базе данных необходимо очистить базу данных между каждым тестом, чтобы избежать помех. В приведенном выше примере тестов транзакций мы сделали это с помощью API EF Core для удаления содержимого таблицы:

using var context = CreateContext();

context.Blogs.RemoveRange(context.Blogs);

context.AddRange(
    new Blog { Name = "Blog1", Url = "http://blog1.com" },
    new Blog { Name = "Blog2", Url = "http://blog2.com" });
context.SaveChanges();

Обычно это не самый эффективный способ очистить таблицу. Если скорость тестирования является проблемой, вы можете использовать необработанный SQL для удаления таблицы.

DELETE FROM [Blogs];

Вы также можете рассмотреть возможность использования пакета respawn , который эффективно очищает базу данных. Кроме того, вам не требуется указывать таблицы для очистки, и поэтому нет необходимости обновлять код очистки при добавлении новых таблиц в модель.

Сводка

  • При тестировании с реальной базой данных стоит различать следующие категории тестов:
    • Тесты только для чтения являются относительно простыми и всегда могут выполняться параллельно с той же базой данных, не беспокоясь об изоляции.
    • Тесты записи представляют больше сложностей, однако транзакции можно использовать, чтобы убедиться, что они правильно изолированы.
    • Тесты транзакций являются наиболее проблемными, требуя от логики сброса базы данных обратно в исходное состояние, а также отключения параллелизации.
  • Разделение этих категорий тестов на отдельные классы может избежать путаницы и случайного вмешательства между тестами.
  • Заранее продумайте ваши данные для тестов с фиксированными значениями и попытайтесь написать тесты так, чтобы они не выходили из строя слишком часто при изменении этих данных.
  • Используйте несколько баз данных для параллелизации тестов, изменяющих базу данных, и, возможно, для разрешения различных конфигураций начальных данных.
  • Если скорость тестирования является проблемой, вы можете рассмотреть более эффективные методы создания тестовой базы данных и очистки данных между запусками.
  • Всегда помните о параллелизации и изоляции тестов.