В случаях, когда ваше приложение имеет нетривиальную схему данных (люди, продукты, заказы, цены, объемы, состояния, зависящие от кучи параметров и т.д.) бывает проще иметь некоторый дамп данных, воссозданный на тестовом окружении, или взятый с продакшна, и использовать его для тестов. В этом случае, может понадобиться несколько дампов данных, для каждого из случаев, которые автоматические тесты должны уметь накатывать и откатывать на тестовое окружение. В этой статье я попытаюсь показать, как это можно сделать, используя fixtures и collections фреймворка xUnit. Все решение построена на базе xUnit версии 2.0 от 16 марта 2015 года.

Сценарий выполнения тестов в контексте


Простейший сценарий для data-driven тестов может выглядеть следующим образом:
  1. создать БД для коллекции тестов
  2. обновить БД до последней версии (опционально)
  3. запустить автоматические тесты
  4. удалить БД

Сейчас не хочу останавливаться на случаях, когда необходимо обновлять БД, т.к. эти технические детали неявляются важными для данный статьи. Но хотел бы отметить, что ADO.NET не позволяют выполнить скрипты, в которых есть GO. Если хотите автоматически накатывать скрипты, постройте свою систему так, чтобы можно было накатывать каждый скрипт по отдельности. Даже библиотека SQL Server Management Objects (SMO) разбивает скрипты по GO и запускает кусочки по отдельности (проверено). Таким образом, 2й пункт не будет рассмотрен в статье. С остальным нам поможет xUnit. С описанием общей концепции контекстов xUnit вы можете познакомиться в их документации.

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

Жизненный цикл Fixtures и Collections


Примеры вы можете найти на GitHub. Начиная с xUnit 2.0 авторы заменили IUseFixture на ICollectionFixture и IClassFixture.

Для демонстрации того, как xUnit создаёт экземпляры классов я создал три сьюта. Два из них должны выполняться в одном контексте.

    public class CollectionFixture : IDisposable
    {
        public CollectionFixture()
        public void Dispose()
    }

    public class ClassFixture : IDisposable
    {
        public ClassFixture()
        public void Dispose()
    }

    [CollectionDefinition("ContextOne")]
    public class TestCollection : ICollectionFixture<CollectionFixture>
    {
        public TestCollection() // TestCollection is never instantiated
    }

    [Collection("ContextOne")]
    public class TestContainerOne : IClassFixture<ClassFixture>, IDisposable
    {
        public TestContainerOne()

        [Fact]
        public void TestOne()

        [Fact]
        public void TestTwo()

        public void Dispose()
    }

    [Collection("ContextOne")]
    public class TestContainerTwo : IDisposable
    {
        public TestContainerTwo()

        [Fact]
        public void TestOne()

        public void Dispose()
    }

    public class TestContainerThree
    {
        [Fact]
        public void TestOne()
    }

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

CollectionFixture : ctor
    ClassFixture : ctor
        TestContainerOne : ctor
            TestContainerOne : TestOne
        TestContainerOne : disposed
        TestContainerOne : ctor
            TestContainerOne : TestTwo
        TestContainerOne : disposed
    ClassFixture : disposed
    TestContainerTwo : ctor
        TestContainerTwo : TestOne
    TestContainerTwo : disposed
CollectionFixture : disposed
TestContainerThree : TestOne

Таким образом, вы можете видеть, чтобы сгруппировать несколько тестов в один контекст можно использовать ICollectionFixture. В то же время, IClassFixture может корректировать настройки окружения для конкретного сьюта. И важно заметить, что конструктор сьюта вызывается для каждого отдельного теста, сколько бы их ни было. В Dispose разумно распологать код очистки соответствующего скоупа (тест, сьют или коллекция).

Детали реализации


Теперь должно быть очевидно, что можно создать класс, выполняющий описанный выше сценарий, и прицепить его к тестам с помощью ICollectionFixture или IClassFixture, в зависимости от конкретных задач. В моём примере использована коллекция, которая восстанавливает БД перед тестами, а в Dispose() дропает её.

Тут стоит отметить о следующих проблемах данного подхода:
  • При восстановлении БД из бекапа используется внутренняя информация о файлах. В случае параллельного выполнения тестов это может приводить к их падаению из-за конфликта имен у восстанавливаемых баз. Чтобы решить проблему, необходимо восстанавливать БД с перемещением файлов. Это есть в примере на GitHub.
  • В общем случае, БД может иметь разное количество файлов (data, log, file stream, и т.д.). Эта ситуация должна правильно обрабатываться. Но для целей этой статьи я предполагаю, что нужны только Data и Log. Остальные файлы игнорируются с помощью частичного восстановления (PARTIAL).

Ниже примеры T-SQL для восстановления и удаления БД.

IF NOT EXISTS (SELECT name FROM master.dbo.sysdatabases WHERE name = '<DBNAME>')
BEGIN
    DECLARE @Table TABLE
    (
      LogicalName VARCHAR(128) ,
      [PhysicalName] VARCHAR(128) ,
      [Type] VARCHAR ,
      [FileGroupName] VARCHAR(128) ,
      [Size] VARCHAR(128) ,
      [MaxSize] VARCHAR(128) ,
      [FileId] VARCHAR(128) ,
      [CreateLSN] VARCHAR(128) ,
      [DropLSN] VARCHAR(128) ,
      [UniqueId] VARCHAR(128) ,
      [ReadOnlyLSN] VARCHAR(128) ,
      [ReadWriteLSN] VARCHAR(128) ,
      [BackupSizeInBytes] VARCHAR(128) ,
      [SourceBlockSize] VARCHAR(128) ,
      [FileGroupId] VARCHAR(128) ,
      [LogGroupGUID] VARCHAR(128) ,
      [DifferentialBaseLSN] VARCHAR(128) ,
      [DifferentialBaseGUID] VARCHAR(128) ,
      [IsReadOnly] VARCHAR(128) ,
      [IsPresent] VARCHAR(128) ,
      [TDEThumbprint] VARCHAR(128)
    )
    INSERT INTO @Table EXEC ( 'RESTORE FILELISTONLY FROM DISK = ''<PATH_TO_BACKUP_FILE>''')

    DECLARE @LogicalNameData varchar(128), @LogicalNameLog varchar(128)
    SET @LogicalNameData=(SELECT LogicalName FROM @Table WHERE Type='D')
    SET @LogicalNameLog=(SELECT LogicalName FROM @Table WHERE Type='L')

    EXEC ('RESTORE DATABASE [<DBNAME>] FROM DISK = ''<PATH_TO_BACKUP_FILE>''
            WITH
            MOVE '''+@LogicalNameData+''' TO ''<PATH>\<DBNAME>_Data.mdf'', 
            MOVE '''+@LogicalNameLog+''' TO ''<PATH>\<DBNAME>_Log.ldf'', 
            REPLACE, PARTIAL'
        )
END

ALTER DATABASE [{0}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE
DROP DATABASE [{0}]

Несколько коментов к примеру на GitHub. Чтобы сделать fixture с контекстом БД универсальной, в конструктору надо передавать connection string и путь к файлу бекапа. Можно использовать класс SqlConnectionStringBuilder, чтобы определить имя базы данных в connection string для других скриптов, т.к. скрипты создания и удаления следует выполнять в контексте базы [master]. Если необходимо удалить БД после некоторого набора тестов, сделайте это принудительно, вызвав Dispose(). Он будет, конечно, вызван и самим xUnit, но это недетерминированно и, возможно, чуть раньше ваши тесты свалятся из-за конфликта баз данных.

Комментарии (0)