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

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

В .net интерфейсы есть, а значит выбор очевиден. Я взял пример из замечательной книги Марка Симана “Внедрение зависимостей в .Net”, чтобы показать некоторые проблемы, которые есть в данном подходе.

Необходимо отобразить простой список рекомендуемых товаров, если список просматривает привилегированный пользователь, то цена всех товаров должна быть снижена на 5 процентов.

Реализуем самым простым способом:

public class ProductService
{
        private readonly DatabaseContext _db = new DatabaseContext();
    
        public List<Product> GetFeaturedProducts(bool isCustomerPreffered)
        {
            var discount = isCustomerPreffered ? 0.95m : 1;
            var products = _db.Products.Where(x => x.IsFeatured);
    
            return products.Select(p => new Product
            {
                Id = p.Id,
                Name = p.Name,
                UnitPrice = p.UnitPrice * discount
            }).ToList();
        }
}

Чтобы протестировать этот метод нужно убрать зависимость от базы — создадим интерфейс и репозиторий:

public interface IProductRepository
{
    IEnumerable<Product> GetFeaturedProducts();
}

public class ProductRepository : IProductRepository
{
    private readonly DatabaseContext _db = new DatabaseContext();
    public IEnumerable<Product> GetFeaturedProducts()
    {
        return _db.Products.Where(x => x.IsFeatured);
    }
}

Изменим сервис, чтобы он использовал их:

public class ProductService
{
    IProductRepository _productRepository;
    public ProductService(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public List<Product> GetFeaturedProducts(bool isCustomerPreffered)
    {
        var discount = isCustomerPreffered ? 0.95m : 1;
        var products = _productRepository.GetFeaturedProducts();

        return products.Select(p => new Product
        {
            Id = p.Id,
            Name = p.Name,
            UnitPrice = p.UnitPrice * discount
        }).ToList();
    }
}

Все готово для написания теста. Используем mock для создания тестового сценария и проверим, что все работает как ожидается:

[Test]
public void IsPrefferedUserGetDiscount()
{
    var mock = new Mock<IProductRepository>();
    mock.Setup(f => f.GetFeaturedProducts()).Returns(new[] {
        new Product { Id = 1, Name = "Pen", IsFeatured = true, UnitPrice = 50}
    });
    
    var service = new ProductService(mock.Object);
    var products = service.GetFeaturedProducts(true);
    
    Assert.AreEqual(47.5, products.First().UnitPrice);
}

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

Сложность и разделение логики


Даже такой простой пример стал сложнее и разделился на две части. Но эти части очень тесно связаны и такое разделение только увеличивает когнитивную нагрузку при чтении и отладки кода.

Множество сущностей и трудоемкость


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

Dependency Injection


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

Протестирована только половина


Это самая серьезная проблема — не протестирован репозиторий. Все тесты проходят, но приложение может работать не корректно (из-за внешних ключей, тригеров или ошибках в самих репозиториях). То есть нужно писать еще и тесты для репозиториев? Не слишком ли уже много возни, ради одного метода? К тому же репозиторий все равно придется абстрагировать от реальной базы и все что мы проверим, как хорошо, он работает с ORM библиотекой.

Mock


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

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

Абстракции протекают


Если вы спрятали свою ORM за интерфейс, то с одной стороны, она не использует всех своих возможностей, а с другой ее возможности могут протечь и сыграть злую шутку. Это касается подгрузки связанных моделей, сохранение контекста … и т.д.

Как видите довольно много проблем с этим подходом. А что насчет второго, с реально базой? Мне кажется он намного лучше.

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

[Test]
public void IsPrefferedUserGetDiscount()
{
    using (var db = new DatabaseContext())
    {
        db.Products.Add(new Product { Id = 1, Name = "Pen", IsFeatured = true, UnitPrice = 50});
        db.SaveChanges();
    };
    
    var products = new ProductService().GetFeaturedProducts(true);
    
    Assert.AreEqual(47.5, products.First().UnitPrice);
}

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

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

Для этого есть решение: начальные «фикстуры» — текстовые файлы (чаще всего в json), содержащие начальный минимальный набор данных. Большим минусом такого решения является необходимость поддерживать эти файлы вручную (изменения в структуре данных, связь начальных данных друг с другом и с кодом тестов).

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

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


  1. vaniaPooh
    28.12.2016 13:16
    +4

    Ок. Работаем дальше.


  1. kekekeks
    28.12.2016 13:21
    +10

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


    Мы у себя, например, запускаем приложение целиком на чистой базе, а затем пинаем настоящими HTTP-запросами. Тесты, соответственно, проверяют ответы и/или изменения в состоянии. Разного рода сервисы вроде отправлялки почты/смс заменяются на моки при инициализации приложения. Таким образом проверяется, что приложение работает и выполняет поставленные задачи, а не то, что конкретные классы по-отдельности работают так, как это было задумано.


    Но следует учитывать, что юнит-тесты всё ещё имеет смысл использовать если у пишется не контроллер/сервис/репозиторий, которые удобно просто запустить в составе приложения, а некий компонент, пригодный для работы в изолированном окружении, то есть, не имеющий зоопарка зависимостей по 30 методов у каждой.


    1. justserega
      28.12.2016 16:57

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


      1. Cromathaar
        28.12.2016 17:31
        +6

        Модульные тесты не просто так называются модульными. Они тестируют модули. В случае C#, который использован для примеров, — классы. Интеграционные тесты тестируют интеграцию модулей друг с другом. Если класс А в своем методе a() вызывает метод b() класса B, то при тестировании a() вы получаете две ситуации:
        — вы мокаете b() и проверяете только логику a() — это модульный тест;
        — вы не мокаете b() и проверяете логику a() в связке с логикой b() — это интеграционный тест.

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

        Природа модульных тестов как самых низлежащих в иерархии тестирования заключается в том, что они максимально «пессимистичны». Предполагается, что у вас сломаться может буквально в каждой функции (и я вам больше скажу — сломается). Интеграционные тесты и выше (приемочные, например), наоборот более «оптимистичны», оттого создается впечатление, что с ними проще и быстрее, так как тест кейсов визуально получается меньше. Однако, как уже отмечали выше, и прогонка их занимает больше времени, уменьшая их ценность именно в цикле разработки, и дебаг потом более сложный и трудоемкий. Ну, представьте, например, что вам не просто эксепшен таймаута к базе вывалился, а неправильный набор данных вернулся. Сиди, программист, гадай-отлаживай, то ли в базе они такие лежат, то ли репозиторий неправильно отфильтровал, то ли бизнес-объекты коряво перелопатили.


        1. justserega
          28.12.2016 18:38

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


          1. Cromathaar
            28.12.2016 21:41

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


            1. justserega
              28.12.2016 22:55

              Вы говорите очень правильные вещи, только несколько сгущаете краски. Если кто-то вносит изменение в случайное место, то мы видим красный тест и разбираемся что произошло — какую проблему вы тут видите? В базе лежит только тестовый кейс и больше ничего — отличие от моков минимально.
              Мне кстати внедрение зависимости нравится больше, и я до сих испытываю душевные терзания, что мы пишем "неправильный" код. В некоторых проектах (например библиотеках) — DI самый правильный выбор. Проблема в том, что он очень плохо работает (может быть я его не умею готовить, но вот странно все умею, а тесты нет).
              Пока что производительность наоборот растет — мы не тратим время на бойлерплейт код и регулярно ловим регресы тестами


              P.S. множество популярных фреймворков (django, yii2, ruby on rails и т.д.) работают с базой и нисколько не страдают — ни от необходимости настроить окружение для тестов, ни от других гипотетических бед. Меня смущает, что мы в .Net игнорируем этот положительный пример как можно по другому работать с тестами.


              1. Cromathaar
                28.12.2016 23:37
                +3

                Я и не говорю, что работать с базой не нужно. Интеграционные тесты так же важны, как и модульные. Но ключевое слово здесь — «так же». Область применения у этих тестов разная. Как уже справедливо заметили в комментариях, 15 модульных тестов могут по отдельности пройти, но один интеграционный, эти 15 модулей объединяющий, упасть. У этих типов тестов разная область применения. Модульные буквально заставляют вас проектировать слабо-сцепленную архитектуру, благодаря чему при обнаружении ошибки в бизнес-правилах, вы имеете возможность максимально быстро локализовать проблему с точностью до функции (или даже строки) и соответственно максимально быстро исправить ее.

                Если хотите реальный пример, то некоторое время назад писали мы геолокационную игру, где была очень мудреная логика чекина — данные читались из одних связанных таблиц, обрабатывались и записывались в другие связанные таблицы. Много работы с базой, много бизнес-правил, и, поскольку были мы еще молоды и глупы, то выглядело это прям, как в вашем примере: метод сервиса и в нем вся каша (с той разницей, что репозитории все-таки были). Модульных тестов мы не писали, были у нас только интеграционные, которые дергали метод сервиса, который так и назывался — CheckIn(), с разными наборами данных. Очевидные ошибки мы отловили достаточно быстро, спору нет, но вот когда пошли дефекты именно в алгоритме — там это не учли, тут это забыли, тут побочный эффект выскочил — каждая дебаг-сессия превращалась в долгий и нудный процесс, потому что понять, что же именно пошло не так, по интеграционному тесту было нельзя. Просто в базе оказывались не те данные, вот и гадай себе. Я твердо убежден, что если бы мы отделили бизнес-логику и написали дня нее модульные тесты, то дело шло бы куда веселее. Хотя бы просто потому, что ошибки в ней возникали гораздо чаще, чем в многочисленных, но достаточно банальных CRUD-операциях с БД.


                1. justserega
                  29.12.2016 04:09

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


                  1. Cromathaar
                    29.12.2016 11:18
                    +1

                    На самом деле это тема для большой и долгой дискуссии у камина :) Программирование — это дисциплина, которая до сих пор опирается на опыт больше, чем на что-либо другое. Т.е. нет каких-то четких правил, серебряных пуль, если угодно, которые говорили бы вам: «делать надо так, иначе работать не будет». Будет. Просто либо лучше, либо хуже. И не в вакууме, а в текущих условиях бюджета, сроков, требований к качеству и согласованному объему работ. Все сложно, короче :)

                    Принципиальных моментов здесь, мне кажется, два — избегание дублирования и защита от изменений. Если у вас в двух или более методах сервиса(ов) выполняется один и тот же запрос к БД, например _db.Products.Where(...), то это явный повод выделить репозитории. Банально для улучшения сопровождаемости. Если вам приходится вносить однотипные изменения — это сигнал к выделению и инкапсуляции этих изменений.

                    Agile-методики, в частности XP, например, английским по белому говорят: делайте максимально просто. Иногда этим злоупотребляют. Там ведь и продолжение есть в виде этапа рефакторинга в TDD, который как раз и занимается тем, что описано в предыдущем абзаце. Как говаривал Роберт Мартин, если не ошибаюсь (а может и ошибаюсь, и это был Кент Бек), «первое изменение пропускаем, от второго защищаемся».


        1. justserega
          28.12.2016 18:39

          Про отладку — в базе лежит только то, что туда положили в arrange фазе


  1. mayorovp
    28.12.2016 13:25
    +6

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


    если список просматривает привилегированный пользователь, то цена всех товаров должна быть снижена на 5 процентов

    А продавать ему будем по какой цене? По показанной — или по той, которая в базе? Если по той, что в базе — то еще ладно, но в первом-то случае логику формирования цены надо выносить в отдельный класс! И тестировать надо этот самый отдельный класс!


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


    1. kekekeks
      28.12.2016 13:35
      +3

      Но нет никакого смысла тестировать код, который делегировал другим всю свою логику работы.

      На самом деле смысл прогонять весь конвейер обработки запроса есть, ибо может выясниться, что все 15 компонент протестированы по-отдельности, но из-за ошибки в контроллере или мидлвари/http-модуле в итоге что-то не работает, причём только на этом конкретном сценарии


      1. mayorovp
        28.12.2016 13:39
        +2

        Да, но это имеет смысл только в интеграционном тесте. Нет смысла тестировать конвейр сам по себе, замокав все этапы.


        1. gorlanovS
          28.12.2016 16:58

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


    1. justserega
      28.12.2016 17:06
      +1

      Я согласен, что пример не очень корректен. Сложно придумать простой пример — получается или сложно или противоречиво.

      И тестировать надо этот самый отдельный класс!
      Представьте, что вы тестируете тот самый класс, вместо моего примера.


      1. mayorovp
        28.12.2016 17:20

        Представил.


        [Test]
        public void IsPrefferedUserGetDiscount() {
            Assert.AreEqual(47.5, BL.GetPrice(
              new Product { Price = 50 }, 
              new User { IsPreffered = true }
            ));
        }


        1. justserega
          28.12.2016 18:00

          Ну и зависимости от базы тут нет ) А вы в базу ведь еще и пишите, представьте пример с записью в базу.


          1. mayorovp
            29.12.2016 09:45
            +2

            Так я об этом и говорю! Не должна бизнес-логика лезть в базу, ни прямо, ни косвенно.


            У вас именно потому и возникли проблемы в тестировании, что вы не отдлелили код, который запрашивает из базы данные, от кода который вычисляет цены.


            1. justserega
              29.12.2016 10:20
              -1

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


              1. mayorovp
                29.12.2016 10:33
                +1

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


  1. mayorovp
    28.12.2016 13:33

    Кстати, для тестирования кода, работающего с БД через EF может подойти Effort Сам с ним так и не поработал — но направление у них считаю правильным.


  1. sshikov
    28.12.2016 13:44
    +7

    Вот только не стоит так обобщать.

    Что-то не так не в .Net/Java и т.д., а исключительно в этом конкретном методе.

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

    Этот метод одновременно достает данные из базы, и вычисляет скидки. Вы намешали в него две совершенно разные функции. Чтож теперь жаловаться, что его сложно тестировать?

    P.S. Пока писал — уже ровно тоже самое выше изложили другими словами.


  1. KirillFormado
    28.12.2016 14:03
    +3

    У тестирования на настоящей базе есть другие недостатки, кроме создания и поддержки скриптов инициализации минимального набора данных.
    Unit тесты можно прогнать любой разработчик сразу, локально и быстро. Для прогона интеграционных тестов, как правило, надо поплясать с настройками локального окружения разработчика.
    Написание нового интеграционного теста может занимать больше времени, чем любого unit теста, из за возни с предварительной настройкой окружения. И сами тесты могу проходить далеко не быстро, а значит их скорее всего не будут запускать локально вообще или не будут запускать часто.
    Они могут падать по причине не связанной с самим тестируемым кодом (были проблемы с соединением с базой, кэшом в redis), а значит будет меньше реакция на упавший интеграционный тест в CI, чем на упавший unit тест.

    Из этого следует, что если какой то метод содержит сложную бизнес логику с множеством кейсов, то писать на каждый такой кейс интеграционный тест не выгодно, так скоро время выполнения всех тестов может перевалить за часы.
    Как представляется общая картина, сложную бизнес логику лучше выделить в классы которые можно спокойно покрыть необходимым количеством unit тестов. Общую работоспособность фичи можно проверить несколькими интеграционными тестами. А еще можно использовать end to end тестирование которое проверяет какой то конкретный бизнес кейс включающий в себя новую фичу.
    Получается такая пирамида тестов


    1. tundrawolf_kiba
      28.12.2016 15:04

      Получается такая пирамида тестов

      Только в ISTQB последний уровень пирамиды разделяется на системное тестирование и приемочное тестирование.


  1. am-amotion-city
    28.12.2016 14:05

    Для этого есть решение: начальные «фикстуры» — текстовые файлы (чаще всего в json)

    Не только.



    1. mayorovp
      28.12.2016 14:16

      И как оно сможет решать проблему инициализации тестовой БД множеством записей с зависимостями?


      1. am-amotion-city
        28.12.2016 14:21

        Оно еще кофе варить не умеет. «Множество записей» только что породила ваша невнимательность в следовании контексту. В заметке была фраза «множество зависимостей».


        Но и множество записей — не проблема в языках, снабженных конструкцией «go to» (или любым циклом).


        1. mayorovp
          28.12.2016 14:25

          Нет, "множество записей" было порождено решаемой задачей. Напомню ее:


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


          1. am-amotion-city
            28.12.2016 14:31

            Мне недосуг сейчас объяснять тривиальные вещи. Я поделился с автором ссылкой на инструмент, позволяющий не использовать фикстуры.


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


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


            1. mayorovp
              28.12.2016 15:04

              А если у двух user должен быть общий department?


              1. am-amotion-city
                28.12.2016 15:07
                -1

                Вы напрасно думаете, что я — это такой бесплатный интерактивный туториал.


                Сделайте фабрику department с трейтом with_two_users. Или, наоборот, user с трейтом with_crowded_department.


                1. mayorovp
                  28.12.2016 16:04

                  "С трейтом"? "Фабрику"? Вы точно говорите о вот этом файле о тридцати трех строках?


                  Вы напрасно думаете, что я — это такой бесплатный интерактивный туториал.

                  Нет, я думаю что вы по-прежнему не понимаете сложность задачи.


                  1. am-amotion-city
                    28.12.2016 16:20
                    -1

                    А, ну значит для .net нормальной имплементации нет, пардон. Я заглянул проверить только java-версию, и то поверхностно.


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


  1. Frank59
    28.12.2016 14:09
    +7

    Статья из разряда вредных советов.


    1. justserega
      28.12.2016 17:24

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


      1. Bringoff
        30.12.2016 09:13
        +1

        Я думаю строго-типизированным языкам вполне есть чему поучиться у динамических.

        Как в том методе вы смешали работу с базой и вычисление цены, так здесь смешиваете в кучу языки и фреймворки. Не надо так.


  1. medvedevia
    28.12.2016 15:27

    Для этого есть решение: начальные «фикстуры» — текстовые файлы (чаще всего в json), содержащие начальный минимальный набор данных. Большим минусом такого решения является необходимость поддерживать эти файлы вручную (изменения в структуре данных, связь начальных данных друг с другом и с кодом тестов).


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


    1. mayorovp
      28.12.2016 16:07

      Так откуда базу наполнять-то? Или вы предлагаете сделать несколько тестов, которые можно запускать только в строго определенном порядке?


      1. medvedevia
        28.12.2016 21:08

        Да! Ведь тесты в идеале должны покрывать всю функциональность. Контрагенты откуда в базе появляются? Вот на эту операцию нужен тест. На добавление товаров тоже нужен тест, и на установку цен, на все нужен тест в идеале. Нельзя протестировать отгрузку со склада, если на складе нет остатков, нужно сначала оприходовать товар, а потом уже можно и списать. Какие-то тесты нужно запускать в определенном порядке, а какие-то необязательно.


        1. justserega
          28.12.2016 22:18

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


        1. mayorovp
          29.12.2016 09:47
          +1

          Такой подход сильно затрудняет отладку. Вот вызывается подряд 20 тестов, двадцатый — красный. Я запущу двадцатый тест в отладчике и пройдусь по шагам. А вам придется запускать в отладчике все тесты.


          1. justserega
            29.12.2016 10:55

            удалено


          1. medvedevia
            29.12.2016 14:51

            Нет, Вы неправильно поняли. Тестов должно быть по максимуму, и перед релизом их нужно все прогонять, надеюсь тут разногласий нет. Но для отладки можно один раз выполнить 19 тестов (или взять готовую базу) и потом многократно запускать 20 тест.


            1. mayorovp
              29.12.2016 15:52

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


              1. medvedevia
                29.12.2016 16:06

                А у вас получится чтоли?)) По факту руками откатываю состояние, если нужно.
                Я вообще уже не понимаю о чем спор, мы же говорим о том, что тест использует настоящую базу, так ведь? И при этом вы утверждаете, что я не могу повторно запустить тест, а вы можете?))


                1. mayorovp
                  29.12.2016 16:14

                  А у меня все тесты запускаются на чистой базе. И откатывают транзакцию в конце.

                  Да, такие тесты можно запускать сколько угодно раз и в любом порядке.


      1. justserega
        28.12.2016 22:16

        Каждый тест начинает работать с чистой базой, в нее заливается базовый набор справочников, остальные таблицы заполняются на этапе Arrange в самом тесте.


  1. SergeyVoyteshonok
    28.12.2016 15:44
    +3

    У вас DbContext должен инжектиться, а не репозиторий. При таком подходе и репозиторий можно протестить. Неправильная архитектура. Если используете EF для мока Db подойдет InMemoryDb.


    1. justserega
      28.12.2016 16:49

      Это вариант, но сложно мокать такие сценарии.


    1. Lailore
      29.12.2016 08:41
      +1

      Это вредный совет. У нас например общая доменная база мобильного приложения и веб сайта/апи. Если последовать вашему совету, то все грустно получается.


      1. SergeyVoyteshonok
        29.12.2016 15:27

        Объясните подробно свою позицию, почему грустно получается? Т.е. по вашему хардкодить DbContext в репозитории как в примере это хорошо?


        1. Lailore
          30.12.2016 08:39

          У каждой платформы свой репозиторий со специфичными для нее зависимостями. Например в веб аби это может быть ms sql, на мобилках это sql lite.

          А так как в весь код доменки инжектиться репозиторий(через интерфейс), то доменка не замечает подмены. + в вебе используется декоратор для кеша и/или проверки прав, а на мобилке это ненужно.


          1. mayorovp
            30.12.2016 09:02

            Если нужно «замечать подмену» — значит, надо заводить разные интерфейсы!


  1. verysimplenick
    28.12.2016 17:25

    Чем не устроил Xunit + class fixture + Collection на тесты с бд? Можно и на некоторые тесты с навешивать очередность. А бд с тестовыми данными разворачивать из sql файла лежащего в ресурсах. Зачем тут json?


    1. justserega
      28.12.2016 17:26

      Мы json как раз не используем, в одну статью все не влезло — получилось слишком длинно. Опубликую наше решение — там как раз, что-то типа class fixtures. Из sql неудобно, там либо много лишнего из продакшн базы… либо тяжело поддерживать руками в sql по мере развития системы


  1. DGolubets
    28.12.2016 17:25
    +3

    Ну конечно, в «Hello world» любое разделение кода будет выглядеть ненужным размножением сущностей. В реальном приложение у вас не 3 строчки кода же?

    Подход с репозиториями — проверенный временем. Не спроста о нем в кинжках пишут.
    Пара советов для правильной реализации в контексте .NET:

    • Отключите LazyLoad
    • Возвращайте IEnumerable либо конкретные типы

    Тогда не будет у вас leaky abstractions и будет счастье.

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


    1. justserega
      28.12.2016 17:43

      Я за разделение кода по ответственности, здесь разделение кода идет только потому что мы решили, что реальная база это слишком сложно.

      — Отключение LazyLoad не спасет от того, что я забуду прописать Include в репозитории и все упадет
      — Сложно мокать графы объектов (особенно если проект динамично развивается)
      — IEnumerable — не понял чем спасет

      Не надо сразу евангелизировать это.

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


      1. mayorovp
        29.12.2016 10:01

        Отключение LazyLoad позволит коду в случае непрописанного Include быстро упасть во время прогона интеграционных тестов. А вот включенный LazyLoad в аналогичном случае тихой сапой неприлично замедлит программу.


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




        IEnumerable спасает от формирования запроса к БД в произвольных местах кода, тем самым прекращая "протечку" ответственности за пределы слоя DAL. В итоге при необходимости сменить схему БД — достаточно переписать один слой. В противном же случае приходится переписывать всю программу.


        1. justserega
          29.12.2016 10:58

          Интеграционных тестов

          Так мы же про unit говорим?


          IEnumerable спасает от формирования запроса к БД

          IEnumerable не спасает от этого.


          1. mayorovp
            29.12.2016 12:29

            Не выдергивайте цитаты из контекста. IEnumerable спасает от формирования запроса к БД в произвольных местах кода.


            1. justserega
              29.12.2016 13:00

              Возврат DbQuery объекта по интерфейсу IEnumerable не заставляет его выполниться (он будет выполненен только при перечислении — как раз в произвольном месте). Нужно возвращать List или array


              1. mayorovp
                29.12.2016 13:06

                А при чем тут выполнение? Речь идет о формировании.


                1. justserega
                  29.12.2016 13:14

                  Это одно и то же в данном случае. В EF же нет кэширования сформированных запросов.

                  var iter = (IEnumerable<Model>)db.Models.Where(x => x.ParentId == 5);
                  

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


              1. mayorovp
                29.12.2016 13:10
                +3

                Вернув IEnumerable, мы запрещаем более высоким слоям дописать к запросу к БД парочку условий в WHERE, добавить JOIN и группировку.


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


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


                1. justserega
                  29.12.2016 13:15

                  Теперь понятно, согласен


  1. Danik-ik
    28.12.2016 22:59
    +1

    Оптимистам от модульного тестирования и хорошей архитектуры могу рассказать, что если на вас внезапно падает поддержка и динамичное развитие спагетти-хардкода, то без тестирования на живой БД в принципе не обойтись :).

    И когда основное предназначение ПО — обмен данными, сиречь заумный экспорт данных из БД в 100500 форматов для кучи получателей, у каждого из которых — собственная гордость, и где бОльшая часть того, что надо тестировать — рабочие SQL запросы, тоже нельзя обойтись без БД, как бы меня ни убеждали, что модульные тесты важнее и круче.

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

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


    1. ggo
      29.12.2016 13:04
      +1

      БД — это Data. Сервер приложений — это Application. Браузер — это Presentation.
      У каждого слоя свои сложности с разработкой и тестированием.
      Очевидно, переносимость подходов к разработке и тестированию в этих разных слоях хоть и коррелирует, но слабо.
      Это не отменяет полезности юнит-тестов в слое Application.
      И это не отменяет полезности интеграционных тестов, когда все слои собраны вместе.
      И это не отменяет все прочие подходы к верификации качества, каждого слоя в отдельности, так и разного их сочетания.


    1. drcolombo
      30.12.2016 00:18
      +1

      Расскажу в кратце, как мы делаем «это». Приложение (очень в кратце, ибо NDA) предназначено для импорта торгов с бирж в формате «мама, роди меня обратно» и экспорте в FpML для дальнейшей обработки соответствующими регуляторами в соответствующих странах. Скорость поступления сообщений о торгах на сегодня в пике порядка 10000 в минуту. В течение максимум 15 минут (а с 2017-го в течение 5 минут) мы обязаны отчитаться перед регулятором. Из чего следует, что права на малейшую ошибку у нас нет вообще. В принципе. Малейший баг теоретически чреват миллионными штрафами конторе от регулятора. Посему, имеем как юнит- (тестирующие каждый public метод и свойство, которое в гетере имеет какой-либо код, отличный от return variable; ), так и интеграционные тесты, как раз таки пользующие почти реальную базу. Сейчас имеем около 1050 тестов, большая часть из которых (процентов 70 как минимум) — интеграционные. В среднем их прогон занимает около 15 минут.

      Теперь насчет почти реальной базы. Отличие от действительно реальной базы — в тестовой нет данных о самих торгах/сделках. Т.е. структура базы — 100% та же, что и на проде (или точнее та, которая будет на проде после очередного деплоя, но это, разумеется, зависит от того, в feature-бранче мы или выкатываем некий hotfix) плюс данные абсолютно всех справочников. Если бэкап продовской базы сейчас порядка 100-150 гиг, то такой вот «тестовой» — около 100 Мб.

      Ну а теперь самое интересное. Есть так называемая TestDatabaseTemplate, которая как раз и есть та самая «тестовая», описанная выше, все изменения структуры или данных справочников применяются сперва в ней и только, когда некий функционал закрыт, изменения применяются уже на QA базе.
      Все интеграционные тесты разбиты по функционалу или же по каким-то бизнес-группам и во время запуска при инициализации assembly такой вот группы тестов создается бэкап TestDatabaseTemplate и восстановление в некую базу с соответствующим суффиксом (например TestDatabaseFpmlGeneration) и подмена реальной базы для всех интеграционных тестов из данной assembly. На сейчас у нас порядка 20-25 различных групп и, соответственно, 20-25 таких вот TestDatabase(suffix) баз. Это дает довольно хороший бонус — получается, что мы всегда можем сделать «срез» работы системы, основываясь на реальных данных, как система ведет себя во время каждого шага обработки/генерации данных.


  1. PashaPash
    29.12.2016 13:18
    +2

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

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

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

    Попробуйте расписать свой же пример, но через написание двух тестов до кода — «Необходимо отобразить простой список рекомендуемых товаров» и «если список просматривает привилегированный пользователь, то цена всех товаров должна быть снижена на 5 процентов» — это покажет тесты с лучшей стороны.

    Интеграционные тесты на базе — не панацея. У них есть один огромный недостаток — они медленные. Т.е. они кажутся хорошей идеей, пока их меньше 10000 штук. Потом они начинают занимать час даже на приличном железе, и вам придется вкладываться в инфраструктуру для билдов. К тому же разработчики перестанут запускать тесты локально и начнут пушить со словами «мне повезет».


    1. PashaPash
      29.12.2016 13:55

      Насчет материализации — я имел ввиду именно факт дополнительного создания еще одного набора (отслеживаемых?) объектов Products, из которых уже потом Linq to Objects делает Select, а не собственно выполнения запроса. Суть в том, что при переписывании готового кода поменялся механизм выполнения запроса, возможно не в лучшую сторону, никто (по легенде поста) это не заметил, и все это подперто одним (!) тестом.


    1. justserega
      29.12.2016 14:39
      -1

      Ценность тестов в том, что код не сломается в будущем и в этом их основное назначение, а не в том, что они магически вам построят архитектуру. И так думаю не только я — Does TDD lead to good design

      Медленность тестов с базой это конечно плохо, пока у нас намного меньше тысячи…


      1. PashaPash
        30.12.2016 15:54

        У меня интеграционных тестов > 8000. Оцените время выполнения при использовании вашего фреймворка :) Медленность тестов с базой — это ужасно плохо.

        TDD и «писать тесты до кода» — разные вещи. Я не приверженец TDD, и не предлагаю вам строить архитектуру только на основании тестов. И вообще (вынуждненно, много легаси кода) предпочитаю интеграционные тесты. Но их можно писать до кода, а можно — после. Одни и те же тесты на один и тот же код могут приносят разный профит в зависимости от момента их написания. И дают разный эффект на мотивацию.

        Тесты после кода:

        [время на написание кода] + [время на ручной протык разработчиком и фикс] + [время на написание тестов]

        Недостатки:

        • Разработчик пишет сразу большие куски функционала, которые нужно покрывать несколькими тестами (как у вас в статье). Из-за этого вы получаете меньше тестов, с дырками в покрытии (уберется необходимость скидки — удалят тест — не будет покрыт кейс выборки без скидки)
        • Разработчик воспринимает код после протыка и фикса как «готовый» — и подсознательно считает тесты ненужными, или считает их «дополнительной работой», а не неотъемлимой частью кода. Которая поможет в будущем кому-то (не ему) вносить изменения.


        Тесты до кода:

        [время на написание теста] + [время на написание кода]

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

        Внедрение тестов на проекте — это проблема психологии, а не техническая (как писать и запускать) и не менеджерская (выделить время). Нужно сделать так, чтобы разработчики чувствовали себя неуютно при попытке нафигачить код без тестов. Им должно быть физически неудобно это делать. «Лень запускать UI и проверять руками, проще тест написать» — вот эффект, который нужно получить.

        Попробуйте писать свои интеграционные тесты до кода. Почувствуете разницу.


        1. justserega
          30.12.2016 16:08

          В такой формулировке согласен полностью. А вы не искали какой-нибудь эмулятор БД? Единственное, что нашел это h2, но непонятно насколько точно он эмулирует


          1. mayorovp
            30.12.2016 16:22
            +1

            1. justserega
              30.12.2016 20:09

              Немного не то, но надо попробовать. Спасибо!


          1. PashaPash
            31.12.2016 00:43
            +1

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

            На текущем проекте — в базе лежит часть логики (права доступа, хаки для иерархических данных и прочее) — и интеграционные тесты требуют реальной базы. Вынести логику из базы полностью не получится (точнее, получится, но работать будет гораздо медленее). И ORM — не EF :)


    1. drcolombo
      30.12.2016 00:22

      Интеграционные тесты на базе — не панацея. У них есть один огромный недостаток — они медленные. Т.е. они кажутся хорошей идеей, пока их меньше 10000 штук. Потом они начинают занимать час даже на приличном железе, и вам придется вкладываться в инфраструктуру для билдов.

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

      К тому же разработчики перестанут запускать тесты локально и начнут пушить со словами «мне повезет».

      1. линейкой по рукам
      2. отрывать руки
      3. увольнять

      (пункты 2 и 3 можно поменять местами по желанию :)
      Если что — про п.3 я серьезно. У нас, правда, пока прецедентов не было, но случаи лишения годового бонуса были, а это почти две зарплаты может быть, что довольно быстро учит нерадивых «везунчиков».


      1. sentyaev
        30.12.2016 05:17
        +1

        1. линейкой по рукам
        2. отрывать руки
        3. увольнять

        Это сработает только в такой ситуации как у вас, когда цена ошибки — огромный штраф.
        Обычно это не так.
        Вот у меня все проще, поэтому интеграционные тесты не запускаю (ибо 10 мин), запускаю только те, что сам только что написал. Если что-то пошло не так, мне прилетит от билд сервера.
        Я это все к тому, что разработчикам не особо нужно запускать тесты (я имею ввиду ВСЕ тесты), для этого есть CI.


      1. PashaPash
        30.12.2016 15:04
        +3

        15 минут на 1000 тестов — это ужасно долго. У меня на проекте 12800 тестов, из них — 9700 серверных, из которых больше 8000 — интеграционные на базе (так сложилось). Время прогона — 20 минут на CI, примерно 15 минут — на дев-машине.

        Если бы мы не вложились в CI (билд сервера с SSD + много локальных хаков в тестовом фреймворке) — мы бы получили почти 3 часа на прогон тестов. Вы готовы ждать 2-3 часа перед каждым пушем? А потом еще 3 часа на прогонку на билд-сервере? Ок, у нас на проекте мы смогли вставить хаки и сократить время выполнения. Но я не уверен, сможете ли вы сделать то же самое в своем коде.

        Fear-driven development? Бить по рукам, лишать премии и прочие кнуты может позволить себе только галера, на которой студентов держат за счет того, что они не знают другой жизни и боятся поменять работу. На продуктовой разработке это просто убьет мотивацию и принесет больше вреда, чем пользы.

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

        Есть более гуманные способы — например снизить потери от «красных» коммитов активным бранчеванием (поломает дев свою ветку — всех остальных это не затронет). Процесс должен помогать разработчикам творить — создавать продукт. А не держать их в узде.


        1. drcolombo
          30.12.2016 19:57
          +1

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

          15 минут на 1000 тестов — это ужасно долго

          Это на дев-машине, на CI порядка 3-5. Но я понимаю, что и это очень долго, т.к. где-то через год объем функционала может вырасти раза в три и количество тестов, боюсь, соответственно.
          Но тем не менее, боюсь, что особо улучшать производительность наших тестов некуда, ибо логика в них такая, что часто запускается несколько потоков на обработку сообщений, а они порой не могут быть обработаны сразу при получении, т.к. зависят от других сообщений, которые биржа еще не послала (я же говорю, что формат «мама, роди меня обратно»)… к примеру, сообщение, инициирующее трейд, может прийти через минуту или две после получения сообщения об изменении или закрытии трейда. Соответственно, и система должна это обрабатывать и тесты должны все это проверять. Т.е. порой задержка в тесте нужна для проверки как раз таки логики работы.
          В «нормальном» приложении, думаю, нет надобности в таких свистоплясках и 15 минут на 1000 тестов — действительно много.

          снизить потери от «красных» коммитов активным бранчеванием (поломает дев свою ветку — всех остальных это не затронет)

          Ваши слова да Богу нашему IT в уши… У нас TFS (хоть и 2015, в котором внутри есть git, но git под запретом), в котором так просто бранч не сделаешь — надо согласовать, создать тикет, получить добро по всей иерархии… Но локально особо продвинутые держат все в локальном git репозитории и делают коммит в TFS только master'а, ну а закоренелые консерваторы так и сидят на «чистом» TFS.


          1. mayorovp
            31.12.2016 17:06

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


            Где-то я видел инструменты тестирования, которые позволяют подменить системные классы в тестах.


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


  1. sentyaev
    30.12.2016 05:01
    +1

    Вам не нужно создавать интерфейсы такие как IRepository. Т.к. EF это уже реализация паттерна репозиторий. И EF поддерживает unittesting из коробки (т.е. без дополнительных абстракций).

    Попробую разобрать ваш пример.
    Тут будут мои предположения о вашем Problem Domain и, скорее всего, я сделаю много неверных предположений.
    Не обращайте внимание на мой стиль форматирования, я так привык.

    private readonly DatabaseContext _db = new DatabaseContext();

    Это проблема. Что такое DatabaseContext? Ключевое слово в название этих классов — Context. Отсюда вопрос, действительно ли Database — это часть вашего Problem Domain?
    Возможно это должно называться ProductContext?
    Далее я предполагаю, что это верно.
    Можно выделить два контекста — ProductContext и SalesContext (не знаю как у вас, но мне кажется логичным, чтобы данные о customers и скидках были тут).

    Можно выделить ProductStore.
    public class ProductsContext : DbContext {
      public virtual DbSet<Product> Products { get; set; }
    }
    public class Product {
      public int Id { get; set; }
      public string Name { get; set; }
      public bool IsFeatured { get; set; }
      public decimal UnitPrice { get; set; }
    }
    


    И Sales. Тут же можно вынести логику скидок в отдельный класс.
    public class SalesContext : DbContext {
      public virtual DbSet<Customer> Customers { get; set; }
    }
    public class Customer {
      public int Id { get; set; }
      public string Name { get; set; }
      public bool IsPreferred { get; set; }
    }
    public class DiscountProvider {
      private static Discount FeaturedCustomerDiscount = new Discount(0.95m);
      private static Discount DefaultDiscount = new Discount(1);
    
      public Discount GetFor(Customer customer) {
        return customer.IsPreferred ? FeaturedCustomerDiscount : DefaultDiscount;
      }
    }
    public class Discount {
      private decimal multiplier;
    
      public Discount(decimal multiplier) {
        this.multiplier = multiplier;
      }
    
      public decimal Calculate(decimal amount) {
        return amount * multiplier;
      }
    }
    


    И собственно магазин. Я вынес этот сервис в приложение, т.к. это Application Service, а не Domain Service (по моему мнению).
    public class ProductService {
      private DiscountProvider discountProvider;
      private ProductsContext productContext;
      private SalesContext salesContext;
    
      public ProductService(ProductsContext productContext, SalesContext salesContext, DiscountProvider discountProvider) {
        this.productContext = productContext;
        this.salesContext = salesContext;
        this.discountProvider = discountProvider;
      }
      public IEnumerable<FeaturedProduct> GetFeaturedProductsFor(int customerId) {
        var customer = salesContext.Customers.FirstOrDefault(c => c.Id == customerId);
        var discount = discountProvider.GetFor(customer);
        var products = productContext.Products.Where(p => p.IsFeatured);
    
        return products.AsEnumerable().Select(p => new FeaturedProduct(p, discount));
      }
    }
    
    public class FeaturedProduct {
      private Discount discount;
      private Product product;
    
      public FeaturedProduct(Product product, Discount discount) {
        this.product = product;
        this.discount = discount;
      }
    
      public int Id => product.Id;
      public string Name => product.Name;
      public decimal UnitPrice => discount.Calculate(product.UnitPrice);
    }
    


    А теперь собственно тест. С помощью метода CreateMock можно замокать контексты.
    [TestFixture]
    public class ProductServiceTests {
      private IQueryable<Product> productsData = new List<Product>
              {
                  new Product { Name = "Nokia 3310", IsFeatured = true, UnitPrice = 100m },
                  new Product { Name = "iPhone" },
                  new Product { Name = "Windows Phone" },
              }.AsQueryable();
      private IQueryable<Customer> customersData = new List<Customer>
              {
                  new Customer { Id = 1, Name = "Leonardo" },
                  new Customer { Id = 2, Name = "Donatello", IsPreferred = true },
                  new Customer { Name = "Mike" },
              }.AsQueryable();
    
      private Mock<Ctx> CreateMock<Ctx, T>(IQueryable<T> data, Expression<Func<Ctx, DbSet<T>>> expr) where Ctx : DbContext where T : class {
        var mockSet = new Mock<DbSet<T>>();
        mockSet.As<IQueryable<T>>().Setup(m => m.Provider).Returns(data.Provider);
        mockSet.As<IQueryable<T>>().Setup(m => m.Expression).Returns(data.Expression);
        mockSet.As<IQueryable<T>>().Setup(m => m.ElementType).Returns(data.ElementType);
        mockSet.As<IQueryable<T>>().Setup(m => m.GetEnumerator()).Returns(data.GetEnumerator());
    
        var mockContext = new Mock<Ctx>();
        mockContext.Setup(expr).Returns(mockSet.Object);
    
        return mockContext;
      }
    
      [Test]
      public void GetFeaturedProductsForRegularCustomer() {
        // Arrange
        var mockProductsContext = CreateMock<ProductsContext, Product>(productsData, c => c.Products);
        var mockCustomersContext = CreateMock<SalesContext, Customer>(customersData, c => c.Customers);
        var customerId = 1;
        var service = new ProductService(mockProductsContext.Object, mockCustomersContext.Object, new DiscountProvider());
    
        // Act
        var products = service.GetFeaturedProductsFor(customerId);
    
        // Assert
        Assert.AreEqual(1, products.Count());
        Assert.AreEqual("Nokia 3310", products.First().Name);
        Assert.AreEqual(100m, products.First().UnitPrice);
      }
    
      [Test]
      public void GetFeaturedProductsForPreferredCustomer() {
        // Arrange
        var mockProductsContext = CreateMock<ProductsContext, Product>(productsData, c => c.Products);
        var mockCustomersContext = CreateMock<SalesContext, Customer>(customersData, c => c.Customers);
        var customerId = 2;
        var service = new ProductService(mockProductsContext.Object, mockCustomersContext.Object, new DiscountProvider());
    
        // Act
        var products = service.GetFeaturedProductsFor(customerId);
    
        // Assert
        Assert.AreEqual(1, products.Count());
        Assert.AreEqual("Nokia 3310", products.First().Name);
        Assert.AreEqual(95m, products.First().UnitPrice);
      }
    }
    


    Еще конфигурация Autofac. Следите за руками) Ни одного интерфейса и unittest'ы работают без базы.
    builder.RegisterType<ProductsContext>();
    builder.RegisterType<SalesContext>();
    builder.RegisterType<DiscountProvider>();
    builder.RegisterType<ProductService>();
    


    Как бонус, тут мы вынесли скидки, и их можно протестировать отдельно.

    Конечно остаются проблемы, такие как протестировать загрузку связанных данных. Но это можно покрыть парой e2e тестов. Entity Framework Testing with a Mocking Framework
    Обратите внимание на раздел Limitations, он как раз об этом.


    1. justserega
      30.12.2016 06:11

      Спасибо, за такой подробный ответ! Про пример в статье я уже говорил — он из книги про DI. Очень сложно с примером оказалось — либо много кода, либо все стараются подвергнуть критике сам пример.

      Интересный подход, попробую примерить его на наши проекты. А как на счет ссылок между контекстами, EF поддерживает такое?


      1. mayorovp
        30.12.2016 09:04

        Между контекстами — нет, не поддерживает. А вот между сущностями — поддерживает даже если некоторые из них не имеют своих DbSet.


        1. justserega
          30.12.2016 09:10

          А миграции тоже работают при многих контекстах с перекрестными ссылками?


          1. mayorovp
            30.12.2016 09:34

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


      1. sentyaev
        30.12.2016 14:00
        +1

        У EF есть один недостаток, который является и его достоинством одновременно. Он диктует как вы должны разрабатывать приложение, а попытка абстрагироваться от него добавляет огромное количество boilerplate кода и в этом случие практически теряется весь смысл от использования EF.

        Нужно понимать, когда используете EF, у вас больше нет слоя доступа к данным, сам EF это и есть слой доступа к данным.
        А то, что в вашем примере DatabaseContext (то что я назвал ProductContext), это получается BoundedContext из DDD. Соответсвенно все модели которые вы используете в этом *Context являются бизнес сущностями и бизнес логику можно да и нужно инкапсулировать в них.

        Конечно у EF есть определенные ограничения на моделирование бизнес сущностей, но это тот самый trade-off, на который мы соглашаемся начиная его использовать.

        Вот неплохая статья о том как моделировать бизнес сущьность и ограничения в EF: Domain modeling with Entity Framework scorecard (Jimmy Bogard).

        А как на счет ссылок между контекстами, EF поддерживает такое?

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