Комментарии 101
Вы сейчас по сути описали интеграционные тесты. Это отличный от юнит-тестирования подход, который позволяет не городить огород из моков и обеспечить лучшее покрытие кода тестами.
Мы у себя, например, запускаем приложение целиком на чистой базе, а затем пинаем настоящими HTTP-запросами. Тесты, соответственно, проверяют ответы и/или изменения в состоянии. Разного рода сервисы вроде отправлялки почты/смс заменяются на моки при инициализации приложения. Таким образом проверяется, что приложение работает и выполняет поставленные задачи, а не то, что конкретные классы по-отдельности работают так, как это было задумано.
Но следует учитывать, что юнит-тесты всё ещё имеет смысл использовать если у пишется не контроллер/сервис/репозиторий, которые удобно просто запустить в составе приложения, а некий компонент, пригодный для работы в изолированном окружении, то есть, не имеющий зоопарка зависимостей по 30 методов у каждой.
— вы мокаете b() и проверяете только логику a() — это модульный тест;
— вы не мокаете b() и проверяете логику a() в связке с логикой b() — это интеграционный тест.
Номенклатура важна, иначе может привести к подмене понятий.
Природа модульных тестов как самых низлежащих в иерархии тестирования заключается в том, что они максимально «пессимистичны». Предполагается, что у вас сломаться может буквально в каждой функции (и я вам больше скажу — сломается). Интеграционные тесты и выше (приемочные, например), наоборот более «оптимистичны», оттого создается впечатление, что с ними проще и быстрее, так как тест кейсов визуально получается меньше. Однако, как уже отмечали выше, и прогонка их занимает больше времени, уменьшая их ценность именно в цикле разработки, и дебаг потом более сложный и трудоемкий. Ну, представьте, например, что вам не просто эксепшен таймаута к базе вывалился, а неправильный набор данных вернулся. Сиди, программист, гадай-отлаживай, то ли в базе они такие лежат, то ли репозиторий неправильно отфильтровал, то ли бизнес-объекты коряво перелопатили.
Вы говорите очень правильные вещи, только несколько сгущаете краски. Если кто-то вносит изменение в случайное место, то мы видим красный тест и разбираемся что произошло — какую проблему вы тут видите? В базе лежит только тестовый кейс и больше ничего — отличие от моков минимально.
Мне кстати внедрение зависимости нравится больше, и я до сих испытываю душевные терзания, что мы пишем "неправильный" код. В некоторых проектах (например библиотеках) — DI самый правильный выбор. Проблема в том, что он очень плохо работает (может быть я его не умею готовить, но вот странно все умею, а тесты нет).
Пока что производительность наоборот растет — мы не тратим время на бойлерплейт код и регулярно ловим регресы тестами
P.S. множество популярных фреймворков (django, yii2, ruby on rails и т.д.) работают с базой и нисколько не страдают — ни от необходимости настроить окружение для тестов, ни от других гипотетических бед. Меня смущает, что мы в .Net игнорируем этот положительный пример как можно по другому работать с тестами.
Если хотите реальный пример, то некоторое время назад писали мы геолокационную игру, где была очень мудреная логика чекина — данные читались из одних связанных таблиц, обрабатывались и записывались в другие связанные таблицы. Много работы с базой, много бизнес-правил, и, поскольку были мы еще молоды и глупы, то выглядело это прям, как в вашем примере: метод сервиса и в нем вся каша (с той разницей, что репозитории все-таки были). Модульных тестов мы не писали, были у нас только интеграционные, которые дергали метод сервиса, который так и назывался — CheckIn(), с разными наборами данных. Очевидные ошибки мы отловили достаточно быстро, спору нет, но вот когда пошли дефекты именно в алгоритме — там это не учли, тут это забыли, тут побочный эффект выскочил — каждая дебаг-сессия превращалась в долгий и нудный процесс, потому что понять, что же именно пошло не так, по интеграционному тесту было нельзя. Просто в базе оказывались не те данные, вот и гадай себе. Я твердо убежден, что если бы мы отделили бизнес-логику и написали дня нее модульные тесты, то дело шло бы куда веселее. Хотя бы просто потому, что ошибки в ней возникали гораздо чаще, чем в многочисленных, но достаточно банальных CRUD-операциях с БД.
Если того требуют условия, то конечно есть смысл разделить и протестировать отдельно. Но у вас ведь не во всей бизнес-логике так? Очень часто это простой метод, который сходил в базу — вытащил данные, собрал бизнес-объект или записал что-то. Зачем в такой ситуации плодить сущности?
Принципиальных моментов здесь, мне кажется, два — избегание дублирования и защита от изменений. Если у вас в двух или более методах сервиса(ов) выполняется один и тот же запрос к БД, например _db.Products.Where(...), то это явный повод выделить репозитории. Банально для улучшения сопровождаемости. Если вам приходится вносить однотипные изменения — это сигнал к выделению и инкапсуляции этих изменений.
Agile-методики, в частности XP, например, английским по белому говорят: делайте максимально просто. Иногда этим злоупотребляют. Там ведь и продолжение есть в виде этапа рефакторинга в TDD, который как раз и занимается тем, что описано в предыдущем абзаце. Как говаривал Роберт Мартин, если не ошибаюсь (а может и ошибаюсь, и это был Кент Бек), «первое изменение пропускаем, от второго защищаемся».
Свои пять копеек про тестирование базы добавлю. У себя на проекте я принципиально использую репозитории для каждого сервиса, и покрываю интеграционными тестами исключительно их. В то время как сервисы покрыты только модульными тестами и всегда общаются с базой через моки. Для проверки взаимодействия сервисов с базой я сразу пишу селениумные e2e-тесты.
Получается, что при изменении бизнес-логики мне крайне редко приходится затрагивать свои «тесты бд», поскольку выборка в моем случае далеко не всегда связана непосредственно с бизнесом. А при изменении выборки мне почти никогда не нужно трогать модульные тесты. Но это конечно полностью специфика моего проекта =)
На самом деле проблема приведенного подхода — в другом. И начинается она — с постановки задачи.
если список просматривает привилегированный пользователь, то цена всех товаров должна быть снижена на 5 процентов
А продавать ему будем по какой цене? По показанной — или по той, которая в базе? Если по той, что в базе — то еще ладно, но в первом-то случае логику формирования цены надо выносить в отдельный класс! И тестировать надо этот самый отдельный класс!
Надо тестировать бизнес-логику (тот самый алгоритм определения цены одного товара для одного пользователя). Надо тестировать репозиторий — он должен правильно фильтровать получаемые из базы данные. Но нет никакого смысла тестировать код, который делегировал другим всю свою логику работы.
Но нет никакого смысла тестировать код, который делегировал другим всю свою логику работы.
На самом деле смысл прогонять весь конвейер обработки запроса есть, ибо может выясниться, что все 15 компонент протестированы по-отдельности, но из-за ошибки в контроллере или мидлвари/http-модуле в итоге что-то не работает, причём только на этом конкретном сценарии
Да, но это имеет смысл только в интеграционном тесте. Нет смысла тестировать конвейр сам по себе, замокав все этапы.
Если правильно вас понял, то считаю что замокав все этапы, мы всё равно можем убедиться что все этапы вызываются как мы и хотели с нужными параметрами. Для меня это преимущество, что это просто дешевле при разработке запустить тест, чем ждать пока поднимется всё окружение с микросервисами чтобы сделать один запрос и узнать что не работает, потому что не ту переменную передал.
И тестировать надо этот самый отдельный класс!Представьте, что вы тестируете тот самый класс, вместо моего примера.
Представил.
[Test]
public void IsPrefferedUserGetDiscount() {
Assert.AreEqual(47.5, BL.GetPrice(
new Product { Price = 50 },
new User { IsPreffered = true }
));
}
Так я об этом и говорю! Не должна бизнес-логика лезть в базу, ни прямо, ни косвенно.
У вас именно потому и возникли проблемы в тестировании, что вы не отдлелили код, который запрашивает из базы данные, от кода который вычисляет цены.
Что-то не так не в .Net/Java и т.д., а исключительно в этом конкретном методе.
Небольшие размеры кода — вовсе не повод считать, что существующая архитектура хорошая.
Этот метод одновременно достает данные из базы, и вычисляет скидки. Вы намешали в него две совершенно разные функции. Чтож теперь жаловаться, что его сложно тестировать?
P.S. Пока писал — уже ровно тоже самое выше изложили другими словами.
Unit тесты можно прогнать любой разработчик сразу, локально и быстро. Для прогона интеграционных тестов, как правило, надо поплясать с настройками локального окружения разработчика.
Написание нового интеграционного теста может занимать больше времени, чем любого unit теста, из за возни с предварительной настройкой окружения. И сами тесты могу проходить далеко не быстро, а значит их скорее всего не будут запускать локально вообще или не будут запускать часто.
Они могут падать по причине не связанной с самим тестируемым кодом (были проблемы с соединением с базой, кэшом в redis), а значит будет меньше реакция на упавший интеграционный тест в CI, чем на упавший unit тест.
Из этого следует, что если какой то метод содержит сложную бизнес логику с множеством кейсов, то писать на каждый такой кейс интеграционный тест не выгодно, так скоро время выполнения всех тестов может перевалить за часы.
Как представляется общая картина, сложную бизнес логику лучше выделить в классы которые можно спокойно покрыть необходимым количеством unit тестов. Общую работоспособность фичи можно проверить несколькими интеграционными тестами. А еще можно использовать end to end тестирование которое проверяет какой то конкретный бизнес кейс включающий в себя новую фичу.
Получается такая пирамида тестов
И как оно сможет решать проблему инициализации тестовой БД множеством записей с зависимостями?
Нет, "множество записей" было порождено решаемой задачей. Напомню ее:
Надо заполнить различимыми данными тестовую реляционную БД, чтобы можно было проверять работу модулей, делающих к ней запросы.
А если у двух user должен быть общий department?
"С трейтом"? "Фабрику"? Вы точно говорите о вот этом файле о тридцати трех строках?
Вы напрасно думаете, что я — это такой бесплатный интерактивный туториал.
Нет, я думаю что вы по-прежнему не понимаете сложность задачи.
Для этого есть решение: начальные «фикстуры» — текстовые файлы (чаще всего в json), содержащие начальный минимальный набор данных. Большим минусом такого решения является необходимость поддерживать эти файлы вручную (изменения в структуре данных, связь начальных данных друг с другом и с кодом тестов).
Не, наполнение базы справочной информацией это тоже тест. Т. е. в данном примере тест должен был начинаться с добавления контрагентов, товаров, установки цен и только потом где-то тестироваться список товаров.
Так откуда базу наполнять-то? Или вы предлагаете сделать несколько тестов, которые можно запускать только в строго определенном порядке?
Каждый тест независим и начинает практически с чистого листа. Базовые вещи, которые не влияют на функциональность можно залить одинаковые для всех. Вся специфика задается в самом тесте, разумеется нужны хелперы, с помощью которых легко это будет сделать.
Такой подход сильно затрудняет отладку. Вот вызывается подряд 20 тестов, двадцатый — красный. Я запущу двадцатый тест в отладчике и пройдусь по шагам. А вам придется запускать в отладчике все тесты.
удалено
У вас в общем случае не получится многократно запускать тест, который меняет внешнее состояние.
Я вообще уже не понимаю о чем спор, мы же говорим о том, что тест использует настоящую базу, так ведь? И при этом вы утверждаете, что я не могу повторно запустить тест, а вы можете?))
Каждый тест начинает работать с чистой базой, в нее заливается базовый набор справочников, остальные таблицы заполняются на этапе Arrange в самом тесте.
А так как в весь код доменки инжектиться репозиторий(через интерфейс), то доменка не замечает подмены. + в вебе используется декоратор для кеша и/или проверки прав, а на мобилке это ненужно.
Подход с репозиториями — проверенный временем. Не спроста о нем в кинжках пишут.
Пара советов для правильной реализации в контексте .NET:
- Отключите LazyLoad
- Возвращайте IEnumerable либо конкретные типы
Тогда не будет у вас leaky abstractions и будет счастье.
А если удариться в демагогию, то кругом компромиссы.
Нет идеального решения, такого чтобы и писать мало кода и все замечательно тестировалось и поддерживалось.
Вы сами должны решить где для вас наилучший баланс.
Однако он там будет только для вас и конкретно вашего проекта.
Не надо сразу евангелизировать это.
— Отключение LazyLoad не спасет от того, что я забуду прописать Include в репозитории и все упадет
— Сложно мокать графы объектов (особенно если проект динамично развивается)
— IEnumerable — не понял чем спасет
Не надо сразу евангелизировать это.
Согласен, тоже самое касается и DI. Я честно пробовал подход с зависимостями… это можно описать как «ежики плакали, кололись, но продолжали есть кактус». Тестов было мало и они не гарантировали, что все окей. Сейчас мы перешли на реальную базу — тестирование из мучения превратилось в удовольствие.
Отключение LazyLoad позволит коду в случае непрописанного Include быстро упасть во время прогона интеграционных тестов. А вот включенный LazyLoad в аналогичном случае тихой сапой неприлично замедлит программу.
С другой стороны, во время разработки первых версий программы (когда главное — чтобы она работала в принципе) LazyLoad действительно сильно помогает.
IEnumerable спасает от формирования запроса к БД в произвольных местах кода, тем самым прекращая "протечку" ответственности за пределы слоя DAL. В итоге при необходимости сменить схему БД — достаточно переписать один слой. В противном же случае приходится переписывать всю программу.
Интеграционных тестов
Так мы же про unit говорим?
IEnumerable спасает от формирования запроса к БД
IEnumerable не спасает от этого.
Не выдергивайте цитаты из контекста. IEnumerable спасает от формирования запроса к БД в произвольных местах кода.
Возврат DbQuery объекта по интерфейсу IEnumerable не заставляет его выполниться (он будет выполненен только при перечислении — как раз в произвольном месте). Нужно возвращать List или array
А при чем тут выполнение? Речь идет о формировании.
var iter = (IEnumerable<Model>)db.Models.Where(x => x.ParentId == 5);
Вот тут ничего не происходит, просто в iter у вас лежит тот же DbQuery и запрос будет формироваться и выполняться, каждый раз при итерировании по iter.
Вернув IEnumerable, мы запрещаем более высоким слоям дописать к запросу к БД парочку условий в WHERE, добавить JOIN и группировку.
Благодаря этому запрету появляется возможность в дальнейшем вернуть кешированный список, использовать хранимую процедуру или отрефакторить зависимости (скажем, заменить три параллельные связи один-ко-многим на одну связь многие-ко-многим).
Также этот запрет позволяет отделить хранимые сущности от возвращаемых, что в свою очередь позволяет вовсе кардинально сменить структуру хранения.
И когда основное предназначение ПО — обмен данными, сиречь заумный экспорт данных из БД в 100500 форматов для кучи получателей, у каждого из которых — собственная гордость, и где бОльшая часть того, что надо тестировать — рабочие SQL запросы, тоже нельзя обойтись без БД, как бы меня ни убеждали, что модульные тесты важнее и круче.
Поэтому любая информация о том, как другие делают «это» (автоматизацию тестирования на реальной БД) вызывает у меня жгучий интерес. Так что с нетерпением жду продолжения.
Что же касается неразмножения сущностей и удобства поддержания тестов — давно уверен, что единообразные тесты надо не хардкодить, а описывать в виде метаданных и формировать на лету. И именно этим я займусь после праздников.
У каждого слоя свои сложности с разработкой и тестированием.
Очевидно, переносимость подходов к разработке и тестированию в этих разных слоях хоть и коррелирует, но слабо.
Это не отменяет полезности юнит-тестов в слое Application.
И это не отменяет полезности интеграционных тестов, когда все слои собраны вместе.
И это не отменяет все прочие подходы к верификации качества, каждого слоя в отдельности, так и разного их сочетания.
Теперь насчет почти реальной базы. Отличие от действительно реальной базы — в тестовой нет данных о самих торгах/сделках. Т.е. структура базы — 100% та же, что и на проде (или точнее та, которая будет на проде после очередного деплоя, но это, разумеется, зависит от того, в feature-бранче мы или выкатываем некий hotfix) плюс данные абсолютно всех справочников. Если бэкап продовской базы сейчас порядка 100-150 гиг, то такой вот «тестовой» — около 100 Мб.
Ну а теперь самое интересное. Есть так называемая TestDatabaseTemplate, которая как раз и есть та самая «тестовая», описанная выше, все изменения структуры или данных справочников применяются сперва в ней и только, когда некий функционал закрыт, изменения применяются уже на QA базе.
Все интеграционные тесты разбиты по функционалу или же по каким-то бизнес-группам и во время запуска при инициализации assembly такой вот группы тестов создается бэкап TestDatabaseTemplate и восстановление в некую базу с соответствующим суффиксом (например TestDatabaseFpmlGeneration) и подмена реальной базы для всех интеграционных тестов из данной assembly. На сейчас у нас порядка 20-25 различных групп и, соответственно, 20-25 таких вот TestDatabase(suffix) баз. Это дает довольно хороший бонус — получается, что мы всегда можем сделать «срез» работы системы, основываясь на реальных данных, как система ведет себя во время каждого шага обработки/генерации данных.
Тесты после кода, особенно если под них приходится править свеженаписанный рабочий и уже протестированный (запущенный пару раз) код — это пустая трата времени.
Вы с большей вероятностью добавите багов, чем почините. Видно даже на вашем примере — вы используете `IEnumerable` в репозитории, что вызывает выполнение запроса и материализацию продуктов прямо в момент вызова `GetFeaturedProducts`, а не позже, как это кажется по коду (и как происходило в оригинальном примере).
Попробуйте расписать свой же пример, но через написание двух тестов до кода — «Необходимо отобразить простой список рекомендуемых товаров» и «если список просматривает привилегированный пользователь, то цена всех товаров должна быть снижена на 5 процентов» — это покажет тесты с лучшей стороны.
Интеграционные тесты на базе — не панацея. У них есть один огромный недостаток — они медленные. Т.е. они кажутся хорошей идеей, пока их меньше 10000 штук. Потом они начинают занимать час даже на приличном железе, и вам придется вкладываться в инфраструктуру для билдов. К тому же разработчики перестанут запускать тесты локально и начнут пушить со словами «мне повезет».
Медленность тестов с базой это конечно плохо, пока у нас намного меньше тысячи…
TDD и «писать тесты до кода» — разные вещи. Я не приверженец TDD, и не предлагаю вам строить архитектуру только на основании тестов. И вообще (вынуждненно, много легаси кода) предпочитаю интеграционные тесты. Но их можно писать до кода, а можно — после. Одни и те же тесты на один и тот же код могут приносят разный профит в зависимости от момента их написания. И дают разный эффект на мотивацию.
Тесты после кода:
[время на написание кода] + [время на ручной протык разработчиком и фикс] + [время на написание тестов]
Недостатки:
- Разработчик пишет сразу большие куски функционала, которые нужно покрывать несколькими тестами (как у вас в статье). Из-за этого вы получаете меньше тестов, с дырками в покрытии (уберется необходимость скидки — удалят тест — не будет покрыт кейс выборки без скидки)
- Разработчик воспринимает код после протыка и фикса как «готовый» — и подсознательно считает тесты ненужными, или считает их «дополнительной работой», а не неотъемлимой частью кода. Которая поможет в будущем кому-то (не ему) вносить изменения.
Тесты до кода:
[время на написание теста] + [время на написание кода]
Тот же код, те же тесты — но нет необходимости в активном ручном протыке. И нет ощущения, что вы делате «дополнительную» работу за других. Тесты начинают помогать разработчику вот прямо сейчас, а не когда-нибудь потом. Это прямой стимул, который заодно создает сайдэффект «код не сломается в будущем». Win-Win.
Внедрение тестов на проекте — это проблема психологии, а не техническая (как писать и запускать) и не менеджерская (выделить время). Нужно сделать так, чтобы разработчики чувствовали себя неуютно при попытке нафигачить код без тестов. Им должно быть физически неудобно это делать. «Лень запускать UI и проверять руками, проще тест написать» — вот эффект, который нужно получить.
Попробуйте писать свои интеграционные тесты до кода. Почувствуете разницу.
На текущем проекте — в базе лежит часть логики (права доступа, хаки для иерархических данных и прочее) — и интеграционные тесты требуют реальной базы. Вынести логику из базы полностью не получится (точнее, получится, но работать будет гораздо медленее). И ORM — не EF :)
Интеграционные тесты на базе — не панацея. У них есть один огромный недостаток — они медленные. Т.е. они кажутся хорошей идеей, пока их меньше 10000 штук. Потом они начинают занимать час даже на приличном железе, и вам придется вкладываться в инфраструктуру для билдов.
Как писал чуть выше — у нас порядка 1050 тестов, из которых порядка 700 — интеграционные, т.е. использующие базу. Прогон в среднем около 15 минут.
К тому же разработчики перестанут запускать тесты локально и начнут пушить со словами «мне повезет».
1. линейкой по рукам
2. отрывать руки
3. увольнять
(пункты 2 и 3 можно поменять местами по желанию :)
Если что — про п.3 я серьезно. У нас, правда, пока прецедентов не было, но случаи лишения годового бонуса были, а это почти две зарплаты может быть, что довольно быстро учит нерадивых «везунчиков».
Если бы мы не вложились в CI (билд сервера с SSD + много локальных хаков в тестовом фреймворке) — мы бы получили почти 3 часа на прогон тестов. Вы готовы ждать 2-3 часа перед каждым пушем? А потом еще 3 часа на прогонку на билд-сервере? Ок, у нас на проекте мы смогли вставить хаки и сократить время выполнения. Но я не уверен, сможете ли вы сделать то же самое в своем коде.
Fear-driven development? Бить по рукам, лишать премии и прочие кнуты может позволить себе только галера, на которой студентов держат за счет того, что они не знают другой жизни и боятся поменять работу. На продуктовой разработке это просто убьет мотивацию и принесет больше вреда, чем пользы.
Если у вас разработчиков приходится лишать премии за нарушение принятого процесса — значит они не понимают, зачем конкретные практики в вашем процессе приняты. И битьем по рукам вы только усугубите ситуацию — спрячете проблему, загоните ее внутрь — но не решите.
Есть более гуманные способы — например снизить потери от «красных» коммитов активным бранчеванием (поломает дев свою ветку — всех остальных это не затронет). Процесс должен помогать разработчикам творить — создавать продукт. А не держать их в узде.
15 минут на 1000 тестов — это ужасно долго
Это на дев-машине, на CI порядка 3-5. Но я понимаю, что и это очень долго, т.к. где-то через год объем функционала может вырасти раза в три и количество тестов, боюсь, соответственно.
Но тем не менее, боюсь, что особо улучшать производительность наших тестов некуда, ибо логика в них такая, что часто запускается несколько потоков на обработку сообщений, а они порой не могут быть обработаны сразу при получении, т.к. зависят от других сообщений, которые биржа еще не послала (я же говорю, что формат «мама, роди меня обратно»)… к примеру, сообщение, инициирующее трейд, может прийти через минуту или две после получения сообщения об изменении или закрытии трейда. Соответственно, и система должна это обрабатывать и тесты должны все это проверять. Т.е. порой задержка в тесте нужна для проверки как раз таки логики работы.
В «нормальном» приложении, думаю, нет надобности в таких свистоплясках и 15 минут на 1000 тестов — действительно много.
снизить потери от «красных» коммитов активным бранчеванием (поломает дев свою ветку — всех остальных это не затронет)
Ваши слова да
Если у вас проблема именно что в задержках — тут поможет "виртуальное время". Нужен инструмент, который позволит быстро перематывать время вперед в тестовой среде.
Где-то я видел инструменты тестирования, которые позволяют подменить системные классы в тестах.
Или же можно сделать свой велосипед, написав свой слой доступа к времени и запретив использовать стандартные средства через инструменты статического анализа.
Правда он платный, хотя бесплатные способные подменять статические и невиртуальные методы я не встречал.
Попробуйте Smocks https://github.com/vanderkleij/Smocks
Проблемы с задержками нет и «перематывать» нам время не зачем. Суть как раз в необходимости проверки того, что поток А будет ожидать завершения обработки другой версии сообщения в потоке Б (или наоборот), или же должен сохранить текущее состояние сообщения в «pending mode», а затем (совершенно уже другой поток), получив «нужную» версию сообщения, сделает его «дообработку» и наконец сохранит сообщение как в базу в нужном состоянии, так и сгенерирует XML по XSLT и «пошлет» его регулятору (тут уже mock).
Я что хочу сказать — эти все задержки, а также различные проблемы с «гонками» у потоков — это реальность нашего приложения и от нее никуда не деться и ее нам нужно корректно тестировать во всех возможных извращенных вариантах :)
Интересный подход, попробую примерить его на наши проекты. А как на счет ссылок между контекстами, EF поддерживает такое?
Нужно понимать, когда используете EF, у вас больше нет слоя доступа к данным, сам EF это и есть слой доступа к данным.
А если Вам понадобится заменить реляционную БД на какой-нибудь другой тип хранилища, как Вы это сделаете? Нам ни что не мешает использовать EF с паттерном репозиторий. Тем более насколько я понимаю, сущность EF это не бизнес объект, а таблица БД и таким образом обращаясь к EF из бизнес логики, мы просто смешиваем логику БД с бизнес логикой. Поправьте меня пожалуйста если я не прав.
То есть сохраняем мы доменный объект, передаем его EF, а он автоматически распределяет данные хранящиеся в нем по разным таблицам.
Сложность и разделение логики
Даже такой простой пример стал сложнее и разделился на две части. Но эти части очень тесно связаны и такое разделение только увеличивает когнитивную нагрузку при чтении и отладки кода.
Возможно стоит правильно организовать структуру проекта, для того что бы по нему было удобно перемещаться и было интуитивно понятно где и что лежит, т.к на мой взгляд если писать жестко связанный код, то в случае большого проекта разобраться там будет еще труднее.
Насколько я помню в подобной литературе авторы пишут примерно о том, что да кода становится больше, но оно того стоит. Я думаю если есть такие книги, то такой подход проверен и работает, просто порог вхождения в него выше хотя бы потому, что многим может быть лень писать «Лишний код» (тоже самое касается тестов).
Эти проблемы — из моей практики, конечно нужно пытаться сделать это разделение простым и логичным, но часто оно выглядит очень искусственным. Кстати почти те же проблемы описываются в вики про минусы Dependency Injection. DI — очень мощная концепция, которая тем не менее имеет приличный оверхед. Я лишь за то, чтобы знать какие есть ей альтернативы и использовать подходящий инструмент.
Что-то не то с тестированием в .NET (Java и т.д.)