Вадим Мартынов — DI-контейнеры в NUnit-тестах

Вадим Мартынов — DI-контейнеры в NUnit-тестах50:51

Информация о загрузке и деталях видео Вадим Мартынов — DI-контейнеры в NUnit-тестах

Автор:

DotNext — конференция для .NET‑разработчиков

Дата публикации:

02.08.2024

Просмотров:

1.6K

Транскрибация видео

Спикер 5

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

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

Я хотела бы представить нашего следующего спикера, Вадим Мартынов, хаос-инженер в Яндексе.

И тут у меня вопрос.

Скажите, пожалуйста, поднимите руки, кто знает, кто такой хаос-инженер?

Поднимите руки, пожалуйста.

Опа.

Так вот, Вадим мне утверждал, что это знают вообще все.

Я чувствовала себя не очень.

Вадим, так вот, не все.

Теперь Вадим расскажет, чем же он занимается.

Спасибо большое.

Прошу.

Спикер 2

Да, хорошо.

Всем привет.

Во-первых, приятно, что вы пришли, что тут полный зал.

Приятно, что там, наверное, полный зал смотрит в онлайн-записи.

Меня тоже посмотрим потом по статистике.

Если что, по поводу дизайна моей презентации в конце будет мой телеграмм.

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

Я прислушаюсь к этому обязательно.

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

Да, меня зовут Вадим, я работаю в Яндекс.Такси.

Ну, для души.

Подождите, я программист.

Это просто для души.

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

Именно об этом я хотел с вами серьезно поговорить, о том, как мы вообще-то пишем тесты наши.

О чем будет сегодняшний доклад?

Ну, во-первых, о том, как обычно пишутся тесты, как они потом поддерживаются и почему так происходит.

О том, что я вижу в этом плохого.

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

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

Давайте вначале разберемся, как мы пишем тесты.

И самый популярный, по крайней мере, по количеству скачиваний на Nugget у нас фреймворк NUnit.

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

При этом в разных компаниях принято по-разному, и вообще-то юнит-тестами...

Называют разные вещи.

Кто-то юнит-тестами вообще все автоматизированные тесты называет.

Кто-то говорит, что есть юнит-тесты, которые модульные, а есть остальные автоматизированные тесты.

Есть некоторая путаница, поэтому я еще хочу сразу посмотреть, что же такое юнит-тесты.

Залезаю в Google, начинаю искать, что такое модульное тестирование или юнит-тестирование и читаю определение.

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

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

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

Что же у нас в реальности?

Да, юнит-тесты пишутся на NUnity.

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

И интеграционные тесты тоже легко писать на NUnity.

Еще тесты, которые запускаются против локально поднятых наших бэкэндов и дергают ручки HTTP API,

Или против тестов стенды дергают ручки HTTP API.

Тоже зачастую пишется на NUnity.

Можно найти много статей, как писать тесты на методы HTTP API.

Там довольно часто будет встречаться фреймворк NUnit.

И даже end-to-end тесты, которые используют веб-драйверы у нас, поднимают браузер, они тоже пишутся на NUnity, потому что он позволяет легко отделять один тест от другого, сценарий от другого.

То есть мы все пишем на NUnity на самом деле.

Давайте посмотрим, как же мы пишем тесты обычно.

Вот там приходит какая-то фича.

Вадим, разработай фичу.

Ты разрабатываешь фичу.

Вначале, конечно, нужно бы написать тесты, а потом начать разрабатывать.

Но кто вообще придумал это ваше тдд?

Поэтому вначале реализуем логику.

Потом начинаем писать тест.

Вот мы написали первый тест.

Там, как правило, нужно перед тем, как сделать в 3A подходе act и assert, нужно подготовить данные, сделать range.

И для этого нужно создать объект нашего класса, например, который мы тестируем.

А еще возможно создать его зависимость или моки его зависимости.

Может подготовить какие-то данные, если это интеграционный тест или функциональный тест.

Есть большая работа перед тем, как сделать этап-акт.

Он у нас написан перед сам экшеном.

Сделали, написали, хорошо, отлично.

Но нам же нужно больше одного теста чаще всего.

Поэтому мы делаем второй тест.

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

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

Здесь мы видим, что у нас два раза встречается один и тот же код.

Что мы делаем с дублированием кода?

Мы его куда-нибудь выносим.

А в NUnity есть как раз специальное место, куда этап Range обычно выносится.

Это метод Setup.

Очень удобно.

Берем, добавляем метод Setup, переносим наш код в метод Setup.

И живем счастливо.

Но нам нужно обычно больше двух тестов, к сожалению.

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

А для негативного сценария нам нужен какой-то неправильные данные, невалидные.

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

Вот мы их создаем, дописываем третий тест, все работает.

А потом нам нужен четвертый тест.

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

Вот так примерно это все будет выглядеть.

Отлично.

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

Поэтому что мы сделаем?

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

Вот так будет выглядеть наш конечный код.

На этом можно остановиться, сделать pull-request, пройти код-ревью, успешно залиться в мастер или в мейн, кому как больше нравится.

Что мы получили?

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

Мы драйв соблюли, вообще кайф.

Еще тесты очень простые, то есть в них только акт и ассерт остался, их вроде бы легко читать.

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

Во-первых...

Тесты удобно читать, но непонятно, в каком именно контексте работает тест.

Нужно прочитать сетап в конкретной Fitch1 тест-фикшере, нужно пойти в BaseTest, посмотреть, какие там есть сетапы, а еще это сложно сделать с помощью стандартных методов тестирования.

какого-нибудь райдера или Visual Studio, потому что этот метод не соотносится с методом конкретного теста.

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

При этом мы не знаем, с какими именно данными работает тест, в том плане, что мы не знаем, когда они и где подготавливаются.

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

Для теста нужно поковыряться немножко.

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

А в каждом тесте нужна только часть этих данных.

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

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

Нехорошо.

И наше лицо становится все более удрученным

Немножко безумным, возможно, но и зачастую злым.

Где мы допустили ошибку вообще?

Вроде я писал тесты, как обычно, все было хорошо.

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

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

Почему все так?

Почему я считаю это ошибкой?

Давайте посмотрим определение.

Если погуглить, что такое test fixture, то можно обнаружить, что ей называют изолированное состояние системы, в которой...

приводит эту систему метод setup, и которая потом чистит teardown.

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

И у нас получилось не test fixture, а...

Как-то по-другому это в Unity называется.

Возможно, фичей и называется в разных фреймворках по-разному.

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

Что в этом всем плохого?

Первым делом то, о чем я уже говорил.

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

Он вначале где-то в сборке, вернее в namespace находит setup.fix, что вызывает у него one-time setup.

Потом у нас есть базовый класс fix, что в нем вызывается setup.

Потом он вызывает...

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

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

Это не очень удобно делать.

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

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

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

Еще как раз то, что связано с подготовкой данных, которые нам не нужны для конкретного теста.

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

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

Это не круто.

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

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

Хочется это все поправить.

И опять же, в чем заключается проблема, что же мы сделали не так?

На мой взгляд, не так мы сделали одну вещь.

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

И

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

Но в тестах мы почему-то этим не пользуемся.

Давайте я приведу пару примеров, как вообще можно делать по-другому.

Во-первых, то, что касается наследования.

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

Оно встречается, как правило, для каких-то инфраструктурных фич.

В тестах мы встречаем наследование довольно часто.

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

Желательно, чтобы у нее еще была у каждой зависимости одна отдельная ответственность.

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

При этом у нас нет инверсии зависимости, потому что мы создаем все дерево прямо в сетапе.

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

Но мы же не пишем так настоящий код.

Почему мы считаем код тестов ненастоящим и недостойным этих прекрасных практик?

Давайте попробуем их внедрить.

А еще время того самого спойлера, который был в начале.

Что нам помогает во внедрении зависимостей и в инверсии зависимостей?

Какая-то штука, которая автоматически это делает за нас.

Благодаря этому мы повсеместно используем делегирование.

И это волшебная штука, к которой мы привыкли.

И из-за того, что она встроена в многие фреймворки, такие как AspNet по умолчанию, мы перестали ее замечать.

Это DI-контейнер.

Очень простая практика, но пока что не совсем понятно, как же это применить внутри тестов.

Давайте разбираться.

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

И хочется избавиться от некоторых вещей перед тем, как переходить к DI-контейнерам.

Первое, от чего я предлагаю избавиться, это атрибут setup.

В чем здесь, на мой взгляд, проблема?

Посмотрите на этот код.

Кто бы пропустил его на код-ревью?

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

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

Вот, что я вижу в этом коде, вызывающего восторг и отвращение у меня.

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

Мы обычно так не делаем, но когда создаем объект,

Мы считаем его инициализацию частью создания.

Поэтому нехорошая практика.

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

А еще совершенно ясно, что не нужно вызывать prepareUser перед каждым вызовом getUser.

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

Но если посмотреть код тестов, который мы пишем с сетапами, то на самом деле по flow он разворачивается ровно в то же самое.

У нас prepare метод вызывается перед каждым тестом, хотя можно было вызывать его один раз перед всеми тестами.

И у нас он инициализируется отдельно от создания класса, в данном случае класса fixture.

Что с этим можно сделать?

Как сделать этот код более похожим на код нашей бизнес-логики?

Давайте сделаем две простые вещи.

Во-первых, уберем нафиг атрибут setup.

Вместо того, чтобы метод prepare инициализировал поля объекта,

Будем их возвращать явно.

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

Что нам поможет это сделать?

Например, здесь видно, что тест 1 и тест 2 используют разные наборы данных.

У нас тест 2 не требует org.id для себя.

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

Просто выделить метод и использовать только метод prepare user там, где нам нужен только юзер, и общий метод prepare там, где нужны и те, и другие данные.

При этом мы воспользовались стандартными средствами Visual Studio или Rider.

Выглядит удобно.

Правда, здесь есть один нюанс с тем, что раньше у нас были поля объекта, и мы могли их в Teardown почистить, например, удалить из базы данных.

Когда у нас поля ушли, мы потеряли эту возможность.

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

условно каким-нибудь простым способом, потом мы это починим более универсально.

Хак, который я предлагаю как временное решение, это просто все созданные сущности складывать в какой-нибудь concurrent back и потом чистить в методе one-time teardown в конце всей фикстуры.

Окей, мы избавились от странного флоу выполнения, избавились от атрибута setup.

Вторая часть, от которой я предлагаю избавиться, это наследование фикстур.

В чем же здесь вообще, на мой взгляд, проблема?

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

Пока что все идет по плану, все хорошо.

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

К счастью, в C-Sharp нет множественного наследования.

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

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

Давайте вынесем

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

То есть и в первом, и во втором случае нам в любом случае в фикстуре нужно будет обозначить

какие провайдеры нам нужны.

Мы просто делаем это разными способами.

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

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

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

Хорошо, мы избавились от атрибута setup, избавились от наследования, но возникла новая проблема.

Даже скорее две.

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

Вообще-то в реальном мире у нас провайдеры будут иметь какие-то зависимости, которые нужно будет тоже создавать или мог и на них создавать, и нам нужно большой граф объектов строить.

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

Это все звучит отвратительно, но здесь стоит остановиться и подумать, так, подождите, конструирование большого графа объектов,

А у нас же есть штука, которая нам помогает это делать автоматически, а не руками.

И мы снова вспоминаем, что тема доклада DI контейнера в NUnit тестах.

Так давайте начинать внедрять хорошее.

И начнем с того, что добавим DI контейнер прямо в фикстуру.

Вначале сделаем это явно.

Мы берем конструктор фикстуры, говорим Service Collection, добавляем туда зависимости, делаем Service Provider и там, где нам нужна какая-то зависимость в явном виде, берем ее из Service Provider, резолвим и каким-то образом используем.

Пока что сильно лучше не стало, потому что у нас все равно от фикстуры к фикстуре будет дублироваться код.

У нас там new service collection будет использоваться каждый раз.

У нас build service provider будет написано каждый раз.

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

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

А еще если у нас вдруг мы часто строим одинаковые под деревья объектов,

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

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

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

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

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

Нам нужно чистить данные.

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

Это не очень удобно.

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

Дело в том, что DI контейнеры реализуют ID disposable.

И когда мы вызываем dispose у контейнера, он вызовет dispose у всех объектов, которые он создал внутри себя.

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

При этом сделать универсальную очистку в классе, просто вызывая сервис-провайдер dispose.sync.

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

Не нужно помнить, где данные создавались.

Они просто возьмут и почистятся.

Очень-очень удобно.

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

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

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

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

Во-первых, создать ровно один базовый класс фикстуры.

Где-то это уже было наследование.

Но в этом случае не для наследования фич или части фич, а для общей инфраструктуры.

И наш fixture-base не будет знать ничего о тестовых сценариях внутри.

Он будет отвечать только за какое-то переиспользование методов.

Что можно вынести туда?

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

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

Здесь же в этот базовый класс можно добавить

One-time teardown, который будет всегда очищать контейнер, соответственно, очищать все провайдеры данных, которые есть внутри.

А еще мы вообще сможем вскрыть сервис провайдер в базовом классе с помощью proxy метода resolve.

И конкретные фиксы у нас вообще не будут знать, что внутри используется DI контейнер.

Мы будем просто писать resolve там, где он нам нужен.

После этого код конкретного теста будет выглядеть вот таким образом.

И, на мой взгляд, это уже очень классно.

У нас нигде не видно полностью, что у нас используются

Мы явно указываем в каждой фиксе, какие именно зависимости от нас ему нужны.

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

И под капотом оно там все собирается чиститься без нашего участия.

Здесь есть один маленький нюанс и недостаток пока что.

Если вы заметите, то все зависимости пока что регистрируются как синглтоны.

Но в некоторых...

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

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

То есть нам нужно добавлять скоупы.

И прелестная вещь в том, что, во-первых, скоупы есть в DI-контейнерах, во-вторых, в NUnity есть инфраструктура, которая позволяет разделить один тест от другого.

Смотрите, как это делается.

Часть первая.

У нас есть тест-контекст, в котором мы можем взять ID-шник конкретного теста и отделять один тест от другого с помощью идентификатора.

Вот мы его сохранили в CurrentTestID.

Второе.

Из-за того, что мы хотим

скорее всего, хотим, чтобы тесты умели выполняться параллельно, нам нужно параллельно работать с разными скоупами в разных тестах, поэтому мы используем Concurred Dictionary, где в качестве ключа будем использовать тот самый тест ID и хранить скоуп DI контейнера в качестве значения.

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

Синглтон зависимости будут одинаковыми внутри скопов.

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

Например, веб-драйвер у нас будет в скопе новый.

Что-то, что мы зарегистрировали как синглтон, будет общим.

И чистить мы это тоже можем универсально.

Соответственно, как только тест завершился, Teardown будет для конкретного теста смотреть скоуп DI контейнера для его теста ID, вызывать для него диспоз, веб-драйвер будет вызывать диспоз, вкладка браузера будет завершаться.

Звучит запутанно.

К счастью, это инфраструктурная часть, которая нужна один раз и которая будет скрыта при написании конкретных тестов.

При этом...

мы сможем регистрировать зависимости как scoped.

Хорошо, мы можем регистрировать singleton, мы можем регистрировать scope.

Что нам еще может понадобиться?

Еще нам может понадобиться какие-то общие провайдеры, которые нужны во всех тестах конкретной fixture.

И нам не хочется их резолвить каждый раз, нам хочется опять «don't repeat yourself» сделать.

Для этого я придумал такое решение, которое позволяет регистрировать и внедрять зависимости в поля конкретной фикстуры.

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

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

понятные вещи для себя.

Во-первых, что нам нужно сделать?

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

Это метод getTestDependences для конкретного класса фикстуры.

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

Для этого используется регистр Dependencies, который внутри Service Collection работает.

И третье, что нам нужно сделать, здесь тоже работает рефлексия, нам нужно в one-time setup, то есть когда инициализируются

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

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

Я вам потом покажу, если вы подойдете.

Но что мы можем делать в итоге?

Вот так выглядит совсем конечный код для нашего теста.

Мы можем делать поле, например, private read-only user preparer, и в него будет добавлено

значение зарегистрированное в DI-контейнере с помощью кода с предыдущего слайда.

Мы можем добавлять addSingletonDependences внутри в явном виде, или можем добавлять addScoped и регистрировать дополнительно что-то в рамках конкретного теста.

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

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

Еще одна вещь, которую мне захотелось добавить.

Мне никогда не нравились методы сетап, потому что они…

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

Но мне часто нужна какая-то функциональность, одинаковая во всех тестах.

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

Было бы клево, если бы я мог потом посмотреть, например, логи по конкретному тесту во всех этих сервисах на моей тестовой площадке, скажем, по trace ID и взять и завести в тесты распределенные трассировки.

Вот что я понял.

Я же могу сделать некоторый...

Класс, который будет запускать, обинтерфейсить его, и он будет в методе setup запускать, например, начинать новую трассировку или делать какое-то подобное действие.

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

И после этого добавить в...

базовый fixture base, такое простое упражнение.

Я просто прохожу и говорю, достань мне, пожалуйста, все сервисы, которые реализуют iSetup и сделай для них setup.

При этом DI-контейнер очень прикольно, ну, по крайней мере, Service Collection очень прикольно работает, что он...

в Get Services резолвит их в том порядке, в котором они были зарегистрированы.

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

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

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

Но это уже такое...

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

Кажется, что ее можно использовать в каких-то других сценариях.

Это все звучит странно и, возможно, интересно.

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

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

Вот что я видел из практики разочарованных людей.

Во-первых, мы все еще можем продолжать по привычке использовать базовые классы тест-фикшера.

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

Что еще может происходить?

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

Есть долгие операции в конструкторах для регистрации действий.

У нас готовится что-то долго, инициализируется долго что-то.

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

Здесь тоже длинные действия вызовут длинное выполнение тестов

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

То же самое касается данных.

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

Мы берем Configuration Provider, кладем его в

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

А еще у нас же уже есть контейнер в приложении.

Почему бы не взять его, не запихнуть в тест-фикшер?

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

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

Какие-то лишние регистрации проходят, может, лишние действия совершаются.

Потом еще непонятно, в каком контексте работает тест.

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

Что мы получили в итоге?

Во-первых, код тестов стал простым в том плане, что по нему легко навигироваться, его легко рефакторить стандартными средствами IDE, при этом можно, прочитав один код,

файл и один класс, понять все зависимости конкретного теста, прочитав один тест, посмотреть, с чем он именно работает.

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

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

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

К нему нужно привыкнуть, и это может быть непросто.

Естественно, у такого подхода наверняка есть подводные камни.

Каждый из них...

стукнется об них самостоятельно и найдёт их сам, я подводные камни обозначил в виде подводных камней.

Они будут у каждого свои.

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

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

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

Нет, не спрашивать в Твиттере, как лучше написать тесты.

Речь, конечно же, идет о фреймворке тестирования XUnit.

Интересной и прекрасной особенностью XUnit является то, что авторы фреймворка XUnit — это люди, которые написали фреймворк NUnit.

Просто XUnit они написали лет через пять после NUnit.

И если зайти на главную страницу фреймворка уже XUnit, то там есть отдельная статья с покаянием, где написано, мы написали NUnit, а потом мы многое поняли.

И вот что поняли.

разработчики NUnit, когда писали XUnit.

Во-первых, параллелить тесты будет проще, если новый объект будет создаваться на каждый тест внутри условной фикстуры.

Во-вторых, сетапы Teardown — это правда какая-то фигня.

Давайте пользоваться конструктором для инициализации и для Teardown использовать Dispose.

А это где-то уже было.

Кажется, мы об этом говорили минут 20 назад.

А еще вот эти все общие инициализаторы test-ficture-setup и test-ficture-teardown, они сложные и запутывающие, поэтому вместо них давайте использовать отдельные классы fixture.

которые можно будет инжектировать через конструктор.

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

Очень удобно использовать делегирование.

Так нафига тогда изобретать что-то внутри NUnit?

Смотрите, если мы посмотрим, как выглядит тест на XUnity, он действительно вроде бы как решает все наши проблемы.

Вот у нас есть, мы помечаем класс теста с помощью iClassFixure, добавляем в конструктор, в данном случае Primary конструктор.

Добро пожаловать в будущее, пора бы уже привыкать к этому синтаксису нашей зависимости DatabaseFixure и AnotherFixure.

и пользуемся ими внутри.

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

Нюанс в том, что Framework XUnit позволяет

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

То есть если нам нужно создать некоторое поддерево объектов, то нам нужно это делать руками внутри Database Fixture или NASA Fixture.

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

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

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

Решение, правда, есть, но короткий ответ, чтобы добавить DI-фреймворк, нам придется переопределить все дерево раннеров для XUnit, начиная с assembly-раннера, потом test-collection-раннера, конкретного тест-раннера, протащить все это DI-фреймворк и каким-то образом внутрь еще протаскивать зависимости, которые будут неявным образом внутри этого раннера использоваться.

Таким образом мы не получим преимущество

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

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

Но как только вам нужно что-то более сложное, то здесь XUnit

И довольно сложно начинает работать.

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

И в этом случае решение на InUnity и внедрение в него DI-фреймворка, как в примере из доклада, выглядит гораздо более простым.

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

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

Спасибо, что послушали.

Надеюсь, что вы сегодня все поедете домой на Яндекс.Такси.

Спикер 5

Вадим, спасибо огромное.

Ну что, у нас есть время на два коротеньких вопроса.

Есть ли у нас вопросы в зале?

Так, вижу руку.

Ну что, ответим?

Нет.

Неправильный ответ.

Прошу.

Спикер 1

Как раз про нет, про негативный сценарий.

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

Спикер 2

Ага.

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

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

По типу не совсем понимаю, но здесь есть несколько подходов.

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

Может быть и для валидных данных, и для невалидных данных тест.

А еще можно вспомнить, что опять же fixture это про...

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

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

Спикер 5

Супер, спасибо большое.

Я вижу там руку.

Спикер 3

Прошу.

Привет, Вадим.

Привет.

Я обещал тебе каверзные вопросы, да?

Первый каверзный вопрос, скорее это не вопрос, а подсказка.

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

Соответственно, это будет примерно как XUnit, только на NUnit.

А вопрос был такой.

Если мне нужны тесты на моках, такое бывает, как мне тогда вот со всем этим жить?

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

Спикер 2

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

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

Спикер 3

То есть прям вот функции, где идет настройка и регистрация.

Спикер 2

В конструкторе, да.

Спикер 3

Прям там и моки настраивать.

В конструкторе, где идет регистрация, прям там же и моки настраиваем.

Спикер 2

Да, да.

Спикер 3

Спасибо.

Спикер 5

Ну что, есть ли у нас еще вопросы?

Вижу руку.

Спикер 4

Здравствуйте.

Спасибо за доклад.

У меня вот такой вот вопрос.

А как у вас вообще, насколько легко проходит онбординг новых бойцов вот в этот вот фреймворк, который вы внутри фреймворка по сути сделали?

И пишите ли вы тесты на этот фреймворк, который тестирует тесты?

Просто я объясню.

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

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

То есть, может быть, пойти лучше в сторону большей гранулярности тестов, то есть делать

Маленькие тесты, их больше, но маленькие.

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

Спасибо.

Спикер 2

Да, спасибо.

Клевый вопрос.

Я на него, наверное, не отвечу за ближайшие 10 минут.

Спикер 5

Мы потом можем уйти в дискуссионную зону и продолжить.

Спикер 2

Поэтому попробую на какую-нибудь часть ответить.

Во-первых, подход со старыми добрыми фабриками.

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

И это уже делает хорошо, все остальное это надстройка над ним.

По поводу того, что непонятно, как это работает.

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

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

какой сетап перетирает данные или что с ними там случается.

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

Когда у нас несколько тестов начинает возникать, то здесь ты уже начинаешь путаться с самим in-unit.

То есть в плане онбординга, на мой взгляд, и в обычный in-unit погружаться тоже не очень просто, особенно когда ты приходишь к каким-нибудь advanced сценариям, типа нужно распараллелить.

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

Спикер 5

Спасибо огромное.

Ну что, предлагаю похлопать Вадиму.

Спасибо огромное, было очень интересно.