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

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

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

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

Большинство систем баз данных в настоящее время можно легко установить как в средах 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 перед переходом в рабочую среду). При использовании Azure Cosmos DB эмулятор Azure Cosmos DB является полезным инструментом как для разработки локально, так и для выполнения тестов.

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

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

При использовании 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 только когда-либо создает экземпляр средства сбора один раз, нам не нужно использовать блокировку вокруг создания базы данных и заполнения, как мы сделали выше.

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

Совет

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

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

В приведенных выше примерах мы использовали и EnsureCreated() перед выполнением тестов, чтобы убедитьсяEnsureDeleted(), что у нас есть актуальная база данных тестирования. Эти операции могут быть немного медленными в некоторых базах данных, что может быть проблемой при переборе изменений кода и повторном выполнении тестов. Если это так, может потребоваться временно закомментировать 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 , который эффективно очищает базу данных. Кроме того, не требуется указывать таблицы для очистки, поэтому код очистки не требуется обновлять при добавлении таблиц в модель.

Итоги

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