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

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

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

Совет

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

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

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

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

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

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

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

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

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

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

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

    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 void GetBlog()
{
    using var context = Fixture.CreateContext();
    var controller = new BloggingController(context);

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

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

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

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

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

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

[HttpPost]
public ActionResult AddBlog(string name, string url)
{
    _context.Blogs.Add(new Blog { Name = name, Url = url });
    _context.SaveChanges();

    return Ok();
}

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

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

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

    context.ChangeTracker.Clear();

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

}

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

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

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

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

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

[HttpPost]
public 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.
    using var transaction = _context.Database.BeginTransaction(IsolationLevel.Serializable);

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

    blog.Url = url;
    _context.SaveChanges();

    transaction.Commit();
    return Ok();
}

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

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

    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() перед выполнением тестов, чтобы убедиться, что у нас есть актуальная тестовая база данных. Эти операции могут быть немного медленными в некоторых базах данных, что может быть проблемой по мере перебора изменений кода и повторного выполнения тестов. Если это так, может потребоваться временно прокомментировать 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] WHERE 1=1;

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

Сводка

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