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

b200b27a05264e22918d78141f9f0903.jpg

Обычным подходом в .NET к тестированию приложений работающих с базой данных является внедрение зависимостей (Dependency Injection). Предлагается отделить код работающий с базой, от основной логики путем создания абстракции, которую в дальнейшем можно подменить в тестах. Это очень мощный и гибкий подход, который тем не менее имеет некоторые недостатки — увеличение сложности, разделение логики, взрывной рост количества типов. Подробнее в предыдущей статье Что-то не то с тестированием в .NET (Java и т.д.) или в Wiki/Dependency Injection.


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



Пример


Как показала предыдущая статья — пример очень важен. Если он неудачный, то критикуется сам пример, а не подход. Здесь я уделил ему больше внимания, но он конечно тоже не идеален:
Есть некое приложение для складского учета товаров. Товары можно перемещать между складами с помощью документов перемещения. Необходим метод, позволяющий получать остатки по указанному складу на указанный момент времени.

Для этого введем следующий метод (его и нужно будет протестировать):
public class ReminesService 
{
    RemineItem[] GetReminesFor(Storage storage, DateTime time) { ... }
}

В статье не будет реализации этого метода, но он есть в репозитории на гитхабе.

Тестовая база данных


Нам понадобится база данных для тестирования. Для простых проектов можно использовать SQLite, это неплохой компромисс между скоростью тестов и их надежностью. Для более сложных случаев лучше использовать такую же БД, что и при разработке. В большинстве случаев это не проблема — MySql и PostgreSql легковесные, для SQLServer есть режим LocalDb.

Если вы работаете с SQLServer, удобно воспользоваться LocalDb режимом для тестовой базы — он намного легче и быстрее полной базы, при этом полностью функционален. Для этого нужно сконфигурировать App.config в тестовом проекте:

Конфигурация для SQLServer LocalDb


    
      


Фреймворк


Так как данный подход очень мало распространен в .NET — почти нет никаких готовых библиотек для его реализации. Поэтому я оформил наработки в этой области в небольшую библиотеку DbTest. Вы можете посмотреть исходники и примеры на гитхаб или установить в проект через nuget. Проект в предварительной версии и может меняться API — так что будьте осторожны.

Начальные данные


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

Чтобы упростить дальнейшее создание тестовых сценариев, необходимо создать минимальный набор общих для системы данных.

Чтобы было немного веселее, давайте в качестве товаров возьмем бутылки с виски. Начнем с модели, у которой нет зависимостей — страна производителя (Country):

public class Countries : IModelFixture
{
    public string TableName => "Countries";

    public static Country Scotland => new Country
    {
        Id = 1,
        Name = "Scotland",
        IsDeleted = false
    };

    public static Country USA => new Country
    {
        Id = 2,
        Name = "USA",
        IsDeleted = false
    };
}

Чтобы фреймворк понял, что это описание начальных данных, класс должен реализовывать интерфейс IModelFixture. Экземпляры моделей объявляются статическими, чтобы обеспечить к ним доступ из других фикстур и тестов. Вы должны явно указывать первичные ключи (Id) и следить за их уникальностью в рамках одной модели.

Теперь можно создавать производителей:

class Manufacturers : IModelFixture
{
    public string TableName => "Manufacturers";

    public static Manufacturer BrownForman => new Manufacturer
    {
        Id = 1,
        Name = "Brown-Forman",
        CountryId = Countries.USA.Id,
        IsDeleted = false
    };

    public static Manufacturer TheEdringtonGroup => new Manufacturer
    {
        Id = 2,
        Name = "The Edrington Group",
        CountryId = Countries.Scotland.Id,
        IsDeleted = false
    };
}

И товары:
public class Goods : IModelFixture
{
    public string TableName => "Goods";

    public static Good JackDaniels => new Good
    {
        Id = 1,
        Name = "Jack Daniels, 0.5l",
        ManufacturerId = Manufacturers.BrownForman.Id,
        IsDeleted = false
    };

    public static Good FamousGrouseFinest => new Good
    {
        Id = 2,
        Name = "The Famous Grouse Finest, 0.5l",
        ManufacturerId = Manufacturers.TheEdringtonGroup.Id,
        IsDeleted = false
    };
}

Обратите внимание на внешние ключи — они не указываются явно, а ссылаются на другую фикстуру.

Такой подход имеет множество преимуществ перед sql-файлами или json файлами фикстур:

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

Важно! У этого подхода есть недостаток — при каждом обращении к статическому свойству создается экземпляр модели и всех зависимых от него моделей (и их зависимостей тоже). Если возникают проблемы с производительностью или циклическими ссылками, то можно исправить это с помощью ленивой инициализации Lazy.
private static Good _famousGrouseFinest = new Lazy(() => new Good
{
    Id = 2,
    Name = "The Famous Grouse Finest, 0.5l",
    ManufacturerId = Manufacturers.TheEdringtonGroup.Id,
    IsDeleted = false
};
public static Good FamousGrouseFinest => _famousGrouseFinest.Value;

Подготовка окружения


Тестовое окружение в первую очередь это база данных, также это могут быть синглтоны и статические переменные (например, в asp.net можно установить HttpContext). Лучше собрать все эти операции в одном месте и запускать перед каждым тестом. Мы назвали у себя такое место — World. Чтобы подготовить базу данных — нужно вызвать метод ResetWithFixtures и передать туда список начальных фикстур.
static class World
{
    public static void InitDatabase()
    {
        using (var context = new MyContext())
        {
            var dbTest = new EFTestDatabase(context);

            dbTest.ResetWithFixtures(
                new Countries(),
                new Manufacturers(),
                new Goods()
            );
        }
    }

    public static void InitContextWithUser()
    {
        HttpContext.Current = new HttpContext(
            new HttpRequest("", "http://your-domain.com", ""),
            new HttpResponse(new StringWriter())
        );
        HttpContext.Current.User = new GenericPrincipal(
            new GenericIdentity("root"),
            new string[0]
            );
    }
}

Возможность задать статические переменные и синглтоны особенно важна при тестировании legacy кода, где не так-то просто поменять архитектуру —, но есть острая необходимость в тестировании. Разделение настройку окружения на несколько методов позволяет подготавливать окружение индивидуального для каждого теста. Например, в unit тестах не используется база и нет смысла очищать для них базу. Или у вас может быть необходимость подготовить различное окружение для разных состояний системы (авторизованный и неавторизованный пользователь).

Создание тестового сценария


В тестах приходится делать много подготовительной работы, Arrange фаза теста самая ответственная и сложная. Поэтому желательно создавать хелперы, которые упростят этот процесс, сделают код более простым для чтения. Одним из удобных механизмов, может быть создание ModelBuilder, который создает сущности, сохраняет их в БД и возвращает экземпляры для дальнейшего использования:
public class ModelBuilder
{
    public MoveDocument CreateDocument(string time, Storage source, Storage dest)
    {
        var document = new MoveDocument
        {
            Number = "#",

            SourceStorageId = source.Id,
            DestStorageId = dest.Id,

            Time = ParseTime(time),
            IsDeleted = false
        };

        using (var db = new MyContext())
        {
            db.MoveDocuments.Add(document);
            db.SaveChanges();
        }

        return document;
    }

    public MoveDocumentItem AddGood(MoveDocument document, Good good, decimal count)
    {
        var item = new MoveDocumentItem
        {
            MoveDocumentId = document.Id,
            GoodId = good.Id,
            Count = count
        };

        using (var db = new MyContext())
        {
            db.MoveDocumentItems.Add(item);
            db.SaveChanges();
        }

        return item;
    }
}

Тестируем


Пришло время собрать все вместе и посмотреть что получилось:
[SetUp]
public void SetUp()
{
    World.InitDatabase(); // подготавливаем базу к каждому тесту
}

[Test]
public void CalculateRemainsForMoveDocuments()
{
    /// ARRANGE - создаем тестовую ситуацию
    var builder = new ModelBuilder();           

    // Приход товаров на удаленный склад
    var doc1 = builder.CreateDocument("15.01.2016 10:00:00", Storages.MainStorage, Storages.RemoteStorage);
    builder.AddGood(doc1, Goods.JackDaniels, 10);
    builder.AddGood(doc1, Goods.FamousGrouseFinest, 15);
           
    // Расход товаров с удаленного склада
    var doc2 = builder.CreateDocument("16.01.2016 20:00:00", Storages.RemoteStorage, Storages.MainStorage);
    builder.AddGood(doc2, Goods.FamousGrouseFinest, 7);

    /// ACT - вызываем тестируемую функцию
    var remains = RemainsService.GetRemainFor(Storages.RemoteStorage, new DateTime(2016, 02, 01));

    /// ASSERT - проверяем результат
    Assert.AreEqual(2,  remains.Count);
    Assert.AreEqual(10, remains.Single(x => x.GoodId == Goods.JackDaniels.Id).Count);
    Assert.AreEqual(8,  remains.Single(x => x.GoodId == Goods.FamousGrouseFinest.Id).Count);
}

Обратите внимание на использование начальных фикстур в коде теста
Storages.MainStorage, Goods.JackDaniels, Goods.FamousGrouseFinest и т.д.

Очень удобно, что под рукой есть все объекты, которые уже есть в базе данных и их можно использовать в любой фазе теста.

Резюме


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

По сравнению с DI, тестирование с настоящей базой имеет следующие преимущества:

  • Меньшее влияние тестов на архитектуру
  • Меньше слоев абстракции — меньше сложность и упрощается чтение кода
  • Больше доверия к тестам, которые на самом деле читают и вставляют данные в базу
  • Быстрее в написании и проще в поддержке

Самая большая ложка дегтя с интеграционными тестами — это время выполнения, они намного медленнее, но это решаемая проблема. По крайней мере серверное время намного дешевле времени разработчика.

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

Полезные ссылки


DbTest (репозиторий с тестовым фреймворком и примерами из статьи)
Smocks (мок для статических системных методов)

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

  • 16 января 2017 в 12:19

    +1

    Это очень мощный и гибкий подход, который тем не менее имеет некоторые недостатки — увеличение сложности, разделение логики, взрывной рост количества типов.

    Разделение логики — это достоинство, а не недостаток. А «взрывной рост» наблюдается только там, где при проектировании допущена ошибка.


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

    Проще, серьезно? «Проще» это только тогда, когда вы для каждого теста создаете нужную ему (и только ему) БД. Но вы представляете себе, насколько это медленно? Поэтому начинают переиспользовать БД между несколькими тестами —, а это уже анти-паттерн shared fixture, ну и понеслась…


    А еще представьте себе, как просто это делать на билд-агентах при каждом билде.


    По крайней мере серверное время намного дешевле времени разработчика.

    Это пока разработчик не начинает простаивать, ожидая выполнения чего-то на сервере.


    Однако в области тестирования есть очень хорошая альтернатива, которая имеет другой набор преимуществ и недостатков.

    Интеграционные тесты — это не альтернатива DI. Интеграционные тесты — это «альтернатива» юнит-тестам; хотя на самом деле, интеграционные тесты — это другой способ тестирования, не способный заменить юнит-тестирование (в обратную сторону тоже верно).

    • 16 января 2017 в 12:24 (комментарий был изменён)

      +1

      Разделение логики — это достоинство, а не недостаток.

      Только если это разделение по ответственности или еще каким-то логическим критериям, а не искусственное — чтобы отделить обращение к базе.


      Но вы представляете себе, насколько это медленно? Поэтому начинают переиспользовать БД между несколькими тестами —, а это уже анти-паттерн shared fixture, ну и понеслась…

      Не надо придумывать того, что не написано — не надо переиспользовать. Не настолько медленно как принято представлять, и есть куда думать, чтобы ускорить.


      Интеграционные тесты — это не альтернатива DI.

      Альтернатива — то как можно тестировать. А интеграционные тесты это дополнение к unit-тестам.

      • 16 января 2017 в 12:37

        0

        Только если это разделение по ответственности или еще каким-то логическим критериям, а не искусственное — чтобы отделить обращение к базе.

        Обращение к БД — это и есть другая ответственность. Поэтому разделение работы с БД и разделение бизнес-логики — это разделение по ответственности.


        Не настолько медленно как принято представлять

        Понимаете ли, я опираюсь не на «принято представлять», а на свою ежедневную деятельность, в которой много интеграционных тестов. И они — медленные. На несколько порядков медленее, чем юнит-тесты.


        , и есть куда думать, чтобы ускорить.

        Например? Потому что в моем опыте «куда ускорить» неизбежно приводит к shared fixture, потому что все рано или поздно упирается во время развертывания чистой БД.


        Альтернатива — то как можно тестировать. А интеграционные тесты это дополнение к unit-тестам.

        Если дополнение — значит, от DI вы отказаться не сможете. Поэтому и не альтернатива.

        • 16 января 2017 в 12:45

          0

          Обращение к БД — это и есть другая ответственность. Поэтому разделение работы с БД и разделение бизнес-логики — это разделение по ответственности.

          Тут можно поспорить — так принято в .NET, что работа с БД это отдельная ответственность. И я считаю, что во многом из-за того, что по другому не протестировать.


          значит, от DI вы отказаться не сможете

          Не могу и не хочу, а еще не хочу микроскопом гвозди забивать. У меня в проектах есть логика, которая тестируется и unit-тестами и интеграционными — потому что там ответственно и сложно, а есть где только интеграционные — потому что ну нет там смысла городить весь этот огород.

          • 16 января 2017 в 12:58

            0

            Тут можно поспорить — так принято в .NET, что работа с БД это отдельная ответственность.

            Далеко не только в .net. Вы Фаулера читали?


            И я считаю, что во многом из-за того, что по другому не протестировать.

            Нет, потому что так сложность меньше.

            • 16 января 2017 в 13:04

              0

              Читал, и наверное его читали создатели Ruby on Rails, Django, Yii2 и тем не менее выбрали эту схему. Я могу привести мнение DHH (создателя RoR), но может быть лучше не авторитетами давить, а аргументированно критиковать?


              И еще раз — я не против DI как такового… я про то, что это часто избыточно.

              • 16 января 2017 в 13:32

                0

                Читал, и наверное его читали создатели Ruby on Rails, Django, Yii2 и тем не менее выбрали эту схему

                Какую «эту»? Слияния логики работы с БД с бизнес-логикой? Или все-таки интеграционного тестирования?


                И еще раз — я не против DI как такового… я про то, что это часто избыточно.

                А я и не про DI, я про разделение ответственностей. DI — лишь один из способов решения этой задачи.

                • 16 января 2017 в 13:40

                  0

                  Слияния логики работы с БД с бизнес-логикой

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


                  Иногда полезно разделить бизнес-логику и обращение к данным. Это оправданно с точки зрения потока данных, алгоритмов — и я не имею к этому никаких претензий. Это может быть оправданно даже с позиции тестов — если вам их надо прогнать тычячи. Но этому есть своя цена и надо знать, что есть и альтернативы.

                  • 16 января 2017 в 13:42

                    0

                    Я слышу про отделение базы только в контексте двух сценариев: тестирование и гипотетическая смена базы в будущем.

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

                    • 16 января 2017 в 13:48

                      0

                      Увеличение уровней абстракции не факт, что ведет к уменьшению сложности. А очень даже наоборот — вам теперь нужно помнить как работают две сущности и их взаимодействие вместо одной.
                      Попробуйте пописать на python — довольно неплохо прочищает мозги. Мне C# милее в сотню раз, но свой отпечаток питон оставил.

                      • 16 января 2017 в 13:51

                        0

                        Увеличение уровней абстракции не факт, что ведет к уменьшению сложности

                        А никто не говорит, заметим, про увеличение уровней абстракции — можно просто заменить одну абстракцию другой.


                        А очень даже наоборот — вам теперь нужно помнить как работают две сущности и их взаимодействие вместо одной.

                        Мне не нужно помнить, как работает DAL, мне нужно знать, какой контракт он выполняет. И это ничем не отличается от того, чтобы помнить, какой контракт поддерживает Entity Framework или ADO.NET.

                  • 16 января 2017 в 13:47

                    0

                    второе похоже на раннюю оптимизацию
                    В качестве мимокрокодила отмечу, что когда-то несколько лет назад тоже на это забил, а теперь сильно жалею об этом, так как для перехода с mysql на postgresql оказывается нужным по сути переписать ВСЁ, так и не перехожу до сих пор
            • 16 января 2017 в 13:09

              0

              Меня бы полностью удовлетворила такая формулировка: есть подход А и Б, вот их плюсы и минусы, решайте, что вам дороже обойдется. К сожалению часто звучит «есть только А, остальное ересь» и это напоминает картинку про PNG и JPEG.

              • 16 января 2017 в 13:33

                0

                Ну вот мы эти плюсы и минусы сейчас обсуждаем.

    • 16 января 2017 в 12:35

      +1

      «Проще» это только тогда, когда вы для каждого теста создаете нужную ему (и только ему) БД. Но вы представляете себе, насколько это медленно?

      Я не знаю как с этим дела в C#, но в своих проектах на Python и Ruby я только так тесты и писал, всё тестирование с постоянным пересозданием этих баз занимало от нескольких секунд до 5–10 минут в зависимости от размера и оптимизированности проекта, имхо вполне приемлемо
      • 16 января 2017 в 12:37

        0

        О… как я ждал этого комментария! Ирония в том, что так делают очень многие, но в мире .NET про это мало кто знает и порицается хуже чем goto))

      • 16 января 2017 в 12:38

        0

        При каком количестве тестов? БД создается на каждый тест?

        • 16 января 2017 в 12:40

          0

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

          • 16 января 2017 в 12:43

            +1

            быстро

            Насколько быстро?

            • 16 января 2017 в 12:47

              0

              Около 600 мс на тест

              • 16 января 2017 в 12:50

                0

                ALTER DATABASE ... SET RECOVERY SIMPLE
                ALTER DATABASE ... SET DELAYED_DURABILITY = FORCED
                

                А если так, то сколько будет? :)
                • 16 января 2017 в 12:53

                  0

                  А что здесь происходит? Можно попробовать замерить

                  • 16 января 2017 в 12:57

                    +1

                    При создании новой базы настройки наследуются от базы model (если не учитывать некоторые нюансы). По дефолту в model стоит FULL. Если база создалась и для нее сделался бекап, то это приведет к разрастанию лога, если нет, то в Вашей базе будет неявно использоваться SIMPLE модель.

                    Для базы с тестами мы также включаем модель восстановления SIMPLE и отложенную запись в лог DELAYED_DURABILITY = FORCED. В теории это самый простой путь без лишних телодвижений снизить время на подготовку данных для теста.

              • 16 января 2017 в 12:59

                0

                …, а у меня на (юнит-)тест уходит меньше 10 мс. Вот вам и порядок.

                • 16 января 2017 в 13:19

                  0

                  Зато при программировании и поддержке цифры меняются местами… там конечно, не будет отличия на порядок, но и время там подороже стоит.

                  • 16 января 2017 в 13:34

                    0

                    Зато при программировании и поддержке цифры меняются местами…

                    Почему вдруг?


                    там конечно, не будет отличия на порядок, но и время там подороже стоит.

                    Понимаете ли, время, потраченное на выполнение интеграционного теста — это тоже мое время.

        • 16 января 2017 в 12:41 (комментарий был изменён)

          +1

          До пары тысяч бывало. Сейчас пилю Python-проект, полтысячи тестов выполняются за 20 секунд (с «пересозданием» БД на каждый тест, ага)
          • 16 января 2017 в 12:58

            0

            полтысячи тестов выполняются за 20 секунд

            У вас БД со всем наполнением создается за 40 мс?


            (ну и да, я вот тут рядом попинал юнит-тесты, на тест уходит меньше 10 мс — и их еще и можно параллелить)

            • 16 января 2017 в 13:07

              0

              Наполнение у меня почти отсутствует, так что почему бы и нет)

              Сейчас попробовал принудительно создавать по тысяче записей перед каждым тестом (честной неоптимизированной тысячей insert-запросов :) — время выполнения увеличилось до 40 секунд, но я всё ещё считаю это приемлемым

              Но всё равно так «в лоб» обычно редко делают, есть куча оптимизаций «пересоздания», в разной степени применимых в каждом конкретном случае)

              • 16 января 2017 в 13:35

                0

                Сейчас попробовал принудительно создавать по тысяче записей перед каждым тестом (честной неоптимизированной тысячей insert-запросов :) — время выполнения увеличилось до 40 секунд, но я всё ещё считаю это приемлемым

                Понимаете ли, в чем дело, у меня тут под боком система, где ~1000 коротких интеграционных тестов идет где-то 40 минут. А начинали с секунд, да.

                • 16 января 2017 в 13:37

                  0

                  И я как-то сильно сомневаюсь, что в этих интеграционных тестах узким местом является или будет являться именно пересоздание БД)
                  • 16 января 2017 в 13:39

                    0

                    Там узкое место — это операции с БД. Включая ее инициализацию в корректное (нужное для каждого отдельного теста) состояние.

                    • 16 января 2017 в 13:44

                      0

                      Ну от операций с БД мы в любом случае никуда не убежим, а топик вроде как лишь про её пересоздание)

                      (Правда, я ничего не могу сказать про ту конкретную реализацию, что описана в топике, так как C# не юзаю)

                      • 16 января 2017 в 13:46

                        0

                        Ну от операций с БД мы в любом случае никуда не убежим

                        Если использовать юнит-тесты вместо интеграционных — еще как убежим.

  • 16 января 2017 в 12:37

    +1

    Самая большая ложка дегтя с интеграционными тестами — это время выполнения, они намного медленнее, но это решаемая проблема.

    У Вас БД для каждого теста пересоздается? Если да, то может помочь Instant File Initialization. Либо лучше базу вообще один раз создать, а потом использовать database snapshot для каждого теста. Начиная с 2016 SP1 эта функциональность и в Express редакции доступна.

    Как сделать быстрее тут когда-то публиковал про Delayed Durability. Для OLTP нагрузки как раз поможет снизить выполнение Ваших тестов.

    • 16 января 2017 в 12:38

      0

      База не пересоздается — в ней отключаются constraints и она чистится, получается очень быстро.

      • 16 января 2017 в 12:40

        0

        Констрейнты включаются после того как в таблицах появились свежие порции данных для нового теста?
        • 16 января 2017 в 12:40

          0

          Конечно

    • 16 января 2017 в 12:57

      0

      Спасибо! Я попробую эти варианты!

      • 16 января 2017 в 13:01

        0

        ИМХО самый лучший вариант: создается база, создается snapshot, накатываются данные, тест проверяется, snapshot откатывается и все по-новому. Тут Вам и минимальная нагрузка на диск + не надо чистить каждый раз базу. В идеале конечно включить Delayed Durability, чтобы снизить WRITELOG ожидания коих при OLTP нагрузке будет достаточно.

© Habrahabr.ru