Обычным подходом в .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
<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <configSections>
      <section name="entityFramework" type="System.Data.Entity.Internal.ConfigFile.EntityFrameworkSection, EntityFramework, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" requirePermission="false" />
    </configSections>
    <entityFramework>
      <defaultConnectionFactory type="System.Data.Entity.Infrastructure.LocalDbConnectionFactory, EntityFramework">
        <parameters>
          <parameter value="MSSQLLocalDB" />
        </parameters>
        </defaultConnectionFactory>
      <providers>
        <provider invariantName="System.Data.SqlClient" type="System.Data.Entity.SqlServer.SqlProviderServices, EntityFramework.SqlServer" />
      </providers>
    </entityFramework>
</configuration>


Фреймворк


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

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


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

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

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

public class Countries : IModelFixture<Country>
{
    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<T>. Экземпляры моделей объявляются статическими, чтобы обеспечить к ним доступ из других фикстур и тестов. Вы должны явно указывать первичные ключи (Id) и следить за их уникальностью в рамках одной модели.

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

class Manufacturers : IModelFixture<Manufacturer>
{
    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<Good>
{
    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<T>.

private static Good _famousGrouseFinest = new Lazy<Good>(() => 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<MyContext>(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 (мок для статических системных методов)
Поделиться с друзьями
-->

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


  1. lair
    16.01.2017 12:19
    +9

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

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


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

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


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


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

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


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

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


    1. justserega
      16.01.2017 12:24

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

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


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

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


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

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


      1. lair
        16.01.2017 12:37
        +2

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

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


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

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


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

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


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

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


        1. justserega
          16.01.2017 12:45
          +1

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

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


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

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


          1. lair
            16.01.2017 12:58

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

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


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

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


            1. justserega
              16.01.2017 13:04

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


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


              1. lair
                16.01.2017 13:32

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

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


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

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


                1. justserega
                  16.01.2017 13:40
                  +1

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

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


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


                  1. lair
                    16.01.2017 13:42
                    +1

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

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


                    1. justserega
                      16.01.2017 13:48

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


                      1. lair
                        16.01.2017 13:51
                        +1

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

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


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

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


                  1. andreymal
                    16.01.2017 13:47
                    +1

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


                    1. Carburn
                      16.01.2017 20:15

                      В чём проблема? Вы писали SQL запросы вручную?


                      1. andreymal
                        16.01.2017 20:17

                        В те времена да. Теперь не пишу)


                        1. Fortop
                          17.01.2017 01:54
                          +1

                          Если у вас SQL запросы локализованы в слое DBAL, то переписать их это не означает переписать «ВСЁ»


                  1. sshikov
                    16.01.2017 20:05

                    Я вижу разделение базы чуть ли не каждый день. И замену одной базы на другую — например, в тестах вместо PROD базы MS SQL используется in-process база H2, а зачастую и in-memory база тоже. Для тестов, да. Это очень, очень широко распространенная практика в мире Java.


            1. justserega
              16.01.2017 13:09
              +2

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


              1. lair
                16.01.2017 13:33

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


    1. andreymal
      16.01.2017 12:35
      +3

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

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


      1. justserega
        16.01.2017 12:37

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


      1. lair
        16.01.2017 12:38

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


        1. justserega
          16.01.2017 12:40
          +1

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


          1. lair
            16.01.2017 12:43
            +1

            быстро

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


            1. justserega
              16.01.2017 12:47
              +1

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


              1. AlanDenton
                16.01.2017 12:50
                +2

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

                А если так, то сколько будет? :)


                1. justserega
                  16.01.2017 12:53

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


                  1. AlanDenton
                    16.01.2017 12:57
                    +3

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

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


              1. lair
                16.01.2017 12:59

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


                1. justserega
                  16.01.2017 13:19

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


                  1. lair
                    16.01.2017 13:34

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

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


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

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


                    1. justserega
                      17.01.2017 05:29
                      -1

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

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


                      1. lair
                        17.01.2017 11:52

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

                        Меньше кода где?


                        он лучше локализован

                        Локализация тут вообще ни при чем.


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

                        10 минут — это круто, да. Хотел бы я вам поверить, но не выходит.


                        А главное, быстрое выполнение тестов — это больше тестов и более частое их выполнение — более раннее обнаружение багов — меньше затрат на саппорт. И упрощение рефакторинга.


        1. andreymal
          16.01.2017 12:41
          +2

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


          1. lair
            16.01.2017 12:58

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

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


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


            1. andreymal
              16.01.2017 13:07
              +1

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

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

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


              1. lair
                16.01.2017 13:35

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

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


                1. andreymal
                  16.01.2017 13:37

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


                  1. lair
                    16.01.2017 13:39

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


                    1. andreymal
                      16.01.2017 13:44
                      +1

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

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


                      1. lair
                        16.01.2017 13:46
                        +2

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

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


    1. ZOXEXIVO
      16.01.2017 14:43
      -7

      Обожаю читать разносы .NET разработчиков от liar


  1. AlanDenton
    16.01.2017 12:37
    +4

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

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

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


    1. justserega
      16.01.2017 12:38

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


      1. AlanDenton
        16.01.2017 12:40

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


        1. justserega
          16.01.2017 12:40

          Конечно


    1. justserega
      16.01.2017 12:57

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


      1. AlanDenton
        16.01.2017 13:01
        +1

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


  1. mihasic
    16.01.2017 16:41
    +3

    ИМХО, как-то у Вас все хардкорно. Маленькая библиотека — прям фрэймворк, который указывает на необходимый дизайн приложения и вносит зависимости (интерфейсы). При это все, что требуется для тестового набора — получить базу.


    Так как данный подход очень мало распространен в .NET — почти нет никаких готовых библиотек для его реализации.

    Подход распространен, нет смысла создавать библиотеку вокруг System.Data.SqlLocalDb. Пример, https://github.com/damianh/SqlStreamStore/blob/master/src/SqlStreamStore.MsSql.Tests/MsSqlStreamStoreFixture.cs
    А для простых вставок данных (если абстрагироваться от логики самого приложения), достаточно и dapper-dot-net как легковесного решения (как пример подхода с минимумом абстракций).


    У нас обычный тест выглядит так:


    • получить базу (connection string)
    • накатить схему
    • добавить тестовые данные
    • выполнить сам тест
    • очистить ресурсы

    Для ускорения, пустая база со схемой создается во время компиляции (post-build). А после, в каждом тесте/наборе, файл копируется и присоединяется (простой скрипт на master: CREATE DATABASE [...] ON (filename = ...)[, (filename = ...)]).


    За наполнение данными отвечает сам тестируемый модуль, т.к. не всегда БД это только лишь CRUD, иногда есть поток сообщений/событий, из которого создаются проекции. Заодно и последнее тестируется.


    Что касается "медленно" — так тут уже решается в рамках проекта, что и как тестировать. Как подсказали, иногда снэпшоты помагают. А иногда и разделение кода по репозиториям, как и сама оптимизация работы продукта.


    1. justserega
      16.01.2017 16:50
      -1

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


  1. Cromathaar
    16.01.2017 16:44
    +1

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

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


    1. justserega
      16.01.2017 16:50
      +1

      Все верно, посыпаю голову пеплом… Исправлю в статье.


    1. Cromathaar
      16.01.2017 16:51

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

      Логика в данном случае никак не разделяется — она как лежала в условном классе репозитория, так и лежит. Взрывного роста количества типов тоже никакого нет — добавляется всего-лишь по одному интерфейсу/абстрактному классу на каждый репозиторий. Отсюда резонный вывод, что сложность если и растет, то крайне незначительно, и увеличение косвенности в данном случае отбивается многократно уменьшением связанности модулей.


      1. justserega
        16.01.2017 16:55

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


        1. Cromathaar
          16.01.2017 16:59

          Что значит «не видно»? А как его было видно до этого? Или вы имеете в виду, что по нажатию F12 в студии вас теперь кидает на файл с абстрактным классом/интерфейсом, а не на класс реализации?


          1. justserega
            16.01.2017 17:04

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


            1. Cromathaar
              16.01.2017 19:05

              Чтобы он был тестируемым, очевидно (я имею в виду здесь модульное тестирование). Если у вас код доступа к БД находится в самой модели (сиречь объекте бизнес-логики), то вполне очевидно, что вы не сможете покрыть эту модель юнит-тестами. Этим вы усложните для себя рефакторинг, а для всего проекта — внесение изменений, т.к. дизайн будет сильно связанным. Если вы пишете утилитку из трех файлов, то конечно на это можно плюнуть в угоду скорости разработки, но на большом проекте в долгосрочной перспективе вы сами роете себе яму.


        1. lair
          16.01.2017 17:01
          +1

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

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


          1. justserega
            16.01.2017 17:02

            И каким же?


            1. lair
              16.01.2017 17:05

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


              (впрочем, в большей части случаев и так никто не указ, чего уж...)


              1. justserega
                16.01.2017 17:09

                Ну вот приехали… то есть бывают случаи когда проще применить интеграционный тест?


                1. lair
                  16.01.2017 17:16

                  Бывают.


                1. Danik-ik
                  16.01.2017 22:30

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

                  Вы умеете тестировать sql-запросы модульными тестами?


          1. Cromathaar
            16.01.2017 17:15
            +1

            Я бы сказал, что из репозитория ее теперь надо вынести обратно :)


    1. Cromathaar
      16.01.2017 16:57

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

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


      1. justserega
        16.01.2017 17:00
        -1

        То, что за пределами нашего привычного опыта тестируют по другому. И это работает и довольно неплохо.


        1. Cromathaar
          16.01.2017 17:03

          Конечно, оно работает. Так же, как и ручное тестирование, например. Вы же не хотите предложить сообществу отказаться от юнит-тестов, потому что приложение можно потыкать руками? :)


          1. justserega
            16.01.2017 17:05

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


            1. Cromathaar
              16.01.2017 18:58

              Я об том и говорю, что эффекты кардинально разные. Юнит-тестирование не предназначено для выявления дефектов. 99% всего интересного ловят функциональные и интеграционные тесты.


  1. oxidmod
    16.01.2017 17:33
    -1

    Я не знаю как в мире дот нета, но в пыхе мы покрываем юнит тестами отдельные классы. Юнит-тесты пишутся так, чтобы работал лишь код тестируемого метода, все остальные зависимости мокаются.
    А функциональными тестами покрываются "юзкейсы". Число этих функциональных тестов равно сумме произведений количества ендпоинтов с которыми взаимодействует клиент приложения на количество вариантов ответов. В целом их выходит не много, потому пересоздавать бд не слишком накладно. Кроме того их запросто можно паралелить, так как они полностью изолированы друг от друга.


  1. KYKYH
    18.01.2017 05:15
    +1

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


    1. lair
      18.01.2017 09:33
      +1

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


    1. Dropsonde
      19.01.2017 13:28
      +1

      То есть у разработчика есть доступ к реальным данным, пусть и к копии базы? Рискну предположить, что не все рассматривают это как вариант.


      1. Fortop
        20.01.2017 16:25

        • На этапе разработки этих данных может тупо не быть в природе
        • Данные могут быть настолько объёмные, что не помещаются в дев.окружение.


        Так что рассматривать не всегда получается


      1. KYKYH
        20.01.2017 23:47

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

        А если у вас нету возможности сделать прикладной тест, то вы всегда будете производить кота в мешке. Все юнит тесты пройдут хоть тысячу раз, а реальный юз-кейс типа “ввёл стопку чеков, нажал кнопку, и получил годовой отчёт” всё равно может не пройти. А если ваша рабочая задача сделать экспорт из своего приложения в другое, и процесс экспорта подразумевает обработку данных? А если на выходе этих данных должно быть несколько гигабайт? С рандомно намоканной абракадаброй (а я такое видел) это не протестируешь, а внедрять всё это в определение какого-нибудь мега-монстро-теста кто угодно сойдёт с ума.

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


        1. lair
          21.01.2017 01:27
          -1

          Так вот, фиктивные данные — они фиктивные, они не реальные. Это тоже мок, просто другого уровня.


          1. Fortop
            21.01.2017 01:57

            Причем печаль этих тестовых БД в том, что автор этих данных «знает» какие правильные данные должны быть со всеми их связями.
            Кейсы для случаев когда в БД оказались некорректные данные зачастую не тестируются вовсе.

            Возможно их и стоило бы опускать при 100% покрытии. Вот только его в сложных случаях не бывает.
            А именно там (в тех самых сложных случаях) тесты приносят наибольшую пользу.


  1. amakhrov
    18.01.2017 08:36

    Мой юз-кейз: Создание отчета по набору фильтров, заданному пользователем.
    Существенная часть логики — конструирование сложного SQL-запроса по этому набору фильтров.


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


    Вопросы к знатокам:


    1. Это еще юнит-тест или уже интеграционный? (тут уточню, что создание и выполнение запроса это еще не все, что делает приложение при генерации отчета — там и пост-обработка, и слияние с данными из других источников).
    2. Можно ли (и имеет ли смысл) такой сценарий тестировать без БД? (речь идет о тестирование именно той части, которая делает первичную выборку данных на основе переданных фильтров)


    1. justserega
      18.01.2017 08:43

      1. Это точно не юнит-тест. По большинству терминологий, юнит-тест — это тест без зависимостей и выполняющийся за очень короткое время
      2. Без БД это очень проблематично и не дает гарантии, что нет ошибок


    1. mayorovp
      18.01.2017 08:51

      Можно использовать для тестирования In-Memory DB. Юнит-тестами это будет если выполнение запроса, пост-обработка, слияние с другими источниками и генерация самого отчета будут проверяться раздельно.


      1. amakhrov
        18.01.2017 10:54

        Спасибо.


        In-Memory DB

        На другую БД заменить непросто, используются специфичные вещи типа JSON-функций в MySQL.


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

        Да, я специально уточнил, что проверяется только логика построения запроса, который должен вернуть ожидаемые сырые данные (пост-обработка тестируется отдельно, уже без БД). Тогда это юнит-тест?


        1. mayorovp
          18.01.2017 11:06

          Тогда это что-то среднее. Модульный тест с элементами интеграционного.