Если вы не работали в IT компании вам вероятно может показаться странным, что наиболее массовый, согласно пирамиде тестирования, вид автотестов - юнит тесты, пишут чаще всего не тестировщики, а разработчики ПО.
Вот как выглядит эта пирамида:
Почему так получилось (причины)
1) Юнит‑тест — это про дизайн кода, а не про “проверить фичу”
Юнит тестирует маленькую единицу поведения (функцию/класс/модуль) в изоляции. Чтобы это сделать хорошо, нужно:
- понимать внутренние инварианты;
- знать, какие зависимости нужно «отрезать» (mock/stub/fake);
- иногда слегка менять архитектуру, чтобы код стал тестопригодным (инъекция зависимостей, выделение интерфейсов, разделение ответственности).
Это ближе к ежедневной работе разработчика, потому что юнит‑тестирование часто идёт рука об руку с рефакторингом и эволюцией дизайна.
2) Юнит‑тесты максимально дешевы там, где их пишут “сразу”
Разработчик пишет код → тут же пишет тест → тут же получает быстрый фидбек.
Если этот цикл разорван (например, тестировщик пишет юниты “потом” или “параллельно”), то:
- контекст уже потерян;
- код уже “застыл” и ломать его страшнее;
- сложнее договориться, что является “правильным” поведением на уровне внутренних деталей.
Итог: писать юнит‑тесты после факта дороже, чем в момент создания.
3) Доступ и ответственность за “внутренности”
Юнит‑тесты часто требуют:
- доступа к приватным/внутренним компонентам;
- понимания контрактов между слоями;
- умения быстро чинить тест, если поменялся код.
Команда обычно “естественным образом” закрепляет это за теми, кто эти внутренности меняет ежедневно — за разработчиками.
Какие были предпосылки (почему индустрия к этому пришла)
- Усложнение систем: микросервисы, асинхронщина, интеграции, сложные доменные правила. Чем сложнее логика, тем выгоднее ловить ошибки “на микроскопическом уровне”.
- CI/CD и скорость поставки: когда релизы частые, без быстрых проверок на уровне кода цикл разработки расползается.
- Сдвиг QA в сторону системного качества: тестировщик всё чаще закрывает риск‑области там, где юнитами “не достать” — интеграции, контракты, наблюдаемость, тест‑стратегия, критические пользовательские потоки, нефункционал, анализ рисков.
Последствия (хорошие и не очень)
Когда юнит‑тесты в основном пишет разработчик, это заметно меняет динамику качества — и обычно в лучшую сторону, но с характерными рисками.
Главный плюс в том, что дефекты ловятся максимально рано: разработчик пишет код и тут же проверяет его на уровне маленьких “единиц” логики. Ошибка обнаруживается ещё до того, как она успела расползтись в интеграцию, затронуть соседние модули, данные или окружение. В результате до интеграционных и системных стадий доходит меньше “мелких” и “локальных” проблем, которые иначе съели бы время на разбор, воспроизведение и поиск причины.
Второе важное преимущество — скорость обратной связи в PR. Юнит‑тесты обычно выполняются быстро, а падение теста часто сразу указывает на конкретный метод/класс и конкретное ожидание. Это ускоряет цикл “написал → проверил → поправил”, повышает уверенность в изменениях и делает код‑ревью более предметным: обсуждают не только “как написано”, но и “какое поведение гарантируется”.
Со временем хорошо написанные юнит‑тесты начинают работать как форма живой документации поведения модуля. Не в смысле комментариев, а в смысле исполняемых примеров: какие входы считаются валидными, какие крайние случаи важны, что именно обещает компонент. Это особенно ценно, когда документации мало или она устаревает — тесты либо актуальны, либо падают, третьего не дано.
Наконец, юнит‑тесты сильно облегчают рефакторинг. Если у модуля есть набор проверок поведения, разработчик может смелее менять внутреннюю структуру кода, не опасаясь случайно сломать важную логику: тесты выступают страховочной сеткой. Это повышает “поддерживаемость” кода в долгую — меньше страха перед изменениями, меньше накопления техдолга.
Но у этой картины есть и обратная сторона. Когда метрики (например, процент покрытия) становятся целью сами по себе, появляется соблазн писать тесты “для галочки”: тестировать очевидное, дублировать реализацию, покрывать строки, а не смыслы. Такие тесты создают иллюзию надёжности — отчёт красивый, а реальные риски могут оставаться нетронутыми.
Ещё один частый минус — чрезмерная привязка тестов к реализации. Если тест проверяет не внешнее поведение (контракт), а внутренние детали (какие методы вызвались, в каком порядке, какие именно приватные структуры изменились), то любой рефакторинг превращается в “ремонт тестов”. Это делает тесты хрупкими и вызывает раздражение: вместо ускорения разработки они начинают её тормозить.
Не говоря уж о том, что тестировать свою работу может показаться странной идеей - как например быть критиком скульптуры, которую вы же и слепили. Ну и из этой же аналогии - хороший скульптор, не всегда такой же хороший критик.
Ну и вследствие описанных выше минусов некоторые команды вообще отказываются от написания юнит тестов, что, конечно же, плохая практика и крайне не рекомендуется.
Как перестать бояться и начать писать юнит тесты?
Так как же все-таки приступить к написанию юнит тестов?
Давайте по шагам разберем стандарты принятые в юнит тестах, а затем вместе создадим основу модуля тестирования.
Первое - определиться, что должны проверять юнит тесты.
Что тестировать юнит-тестами:
1. Бизнес-логику
Если метод что-то считает, преобразует, фильтрует, сортирует — это твоя цель.
Тестируешь разные комбинации входных данных и проверяешь, что результат корректен.
2. Валидацию входных данных
Проверяешь, что метод корректно реагирует на невалидные входные данные: выбрасывает исключения, возвращает ошибки и т.д.
3. Пограничные случаи
Минимумы, максимумы, пустые коллекции, null — всё, что может сломать логику.
4. Поведение при разных условиях
Если метод должен вести себя по-разному в зависимости от флагов, конфигурации, ролей пользователя — это тоже юнит-тест.
5. Контракты и интерфейсы
Если ты пишешь реализацию интерфейса — тестируешь, что она соответствует ожиданиям (например, сортировка, фильтрация, агрегация).
Что не стоит тестировать юнит-тестами:
- Базы данных — это уже интеграционные тесты.
- HTTP-запросы — тоже не сюда, если только ты не мокируешь клиента.
- UI/вёрстку — это для e2e или UI-тестов.
- Логирование, трекинг, метрики — если только не критично для логики.
Что НЕ тестируют юнит-тесты:
- Интеграцию и взаимодействие между разными модулями, сервисами, базами данных, файловой системой или внешними API.
- Производительность, нагрузку и масштабируемость; для этого нужны другие виды тестирования (нагрузочное, стресс-тестирование).
- Бизнес-логику в контексте пользователя (user story); они проверяют как код работает, а не что он должен делать для пользователя.
- Нефункциональные требования: безопасность, отказоустойчивость, юзабилити, совместимость.
- Приватные методы напрямую; их логика проверяется через публичные методы, чтобы избежать привязки тестов к деталям реализации.
- Очевидное поведение; если что-то очевидно, тратить на это тесты неэффективно, лучше сосредоточиться на неочевидных сценариях.
- Системные ошибки в целом; они видны только на этапе интеграционного или системного тестирования.
Характеристики хороших модульных тестов
Существует несколько важных характеристик, определяющих хороший модульный тест:
- Быстрый: для зрелых проектов не редкость иметь тысячи модульных тестов. Модульные тесты должны занять мало времени для выполнения. Миллисекунд.
- изолированный: модульные тесты являются автономными, могут выполняться в изоляции и не зависят от каких-либо внешних факторов, таких как файловая система или база данных.
- повторяющийся. Выполнение модульного теста должно соответствовать его результатам. Тест всегда возвращает один и тот же результат, если вы ничего не измените между выполнением.
- сам себя проверяющий: тест должен автоматически определять, прошел он или провалился без какого-либо взаимодействия с человеком.
- должен быть быстрым и легким в написании: написание модульного теста не должно занимать непропорционально много времени по сравнению с написанием тестируемого кода. Если вы обнаружите, что тестирование кода занимает большое количество времени по сравнению с написанием кода, рассмотрите более тестируемый дизайн.
Стандарты именования Unit тестов
Имя теста должно состоять из трех частей:
- Имя проверяемого метода
- Сценарий, в котором тестируется метод
- Ожидаемое поведение при вызове сценария
Структура теста (AAA)
Подготовка - Действие - Проверка
Избегайте логики написания кода в модульных тестах
Юнит‑тесты должны быть максимально простыми: «подготовил входные данные → вызвал метод → проверил результат». Как только в тесте появляется “мини‑программа” с циклами, ветвлениями и ручной сборкой строк, тест начинает жить собственной жизнью — и ты внезапно получаешь второй кусок логики, который тоже может ошибаться. В итоге падение теста перестаёт однозначно означать «сломался продуктовый код»: иногда ломается сам тест, потому что его логика неверна или стала неактуальной.
Если тебе кажется, что без foreach/if в тесте никак, это почти всегда сигнал, что ты пытаешься одним тестом покрыть несколько независимых сценариев. Гораздо надёжнее разделить их на отдельные тест‑кейсы или вынести вариативность в параметризованный тест — так ты сохраняешь читаемость и точную диагностику.
Плохой пример (тест скрывает несколько ожиданий и копит логику внутри себя):
Хороший пример (каждый кейс прозрачен: вход → ожидаемое поведение, без «программирования в тесте»):
Не тестируйте приватные методы
Частая ловушка при первых шагах в юнит‑тестах — начать “прицельно” тестировать приватные хелперы, потому что они кажутся наиболее понятными и изолированными. Проблема в том, что приватные методы — это деталь реализации. Ты не обещаешь их поведение внешнему миру, ты обещаешь поведение публичного API. Если привязаться тестами к приватной структуре, то любой рефакторинг (даже безопасный) превращается в переписывание тестов, а не в улучшение кода.
Возьмём код, где публичный метод использует приватный шаг нормализации:
Правильнее тестировать поведение публичного метода — именно то, что важно пользователю класса:
Обработка статических зависимостей через “швы”
Один из ключевых принципов юнит‑тестирования — контроль над окружением тестируемого кода. Этот контроль ломается, когда внутрь логики попадают статические или глобальные зависимости: текущее время, случайные числа, файловая система, сеть, переменные окружения. С такими зависимостями тест становится непредсказуемым: сегодня проходит, завтра падает — и это не потому, что код сломался, а потому что изменились внешние условия.
Пример кода, который трудно стабильно тестировать, потому что он опирается на текущее время:
Проблема очевидна: тест “на утро” будет то зелёным, то красным — зависит от времени запуска.
Чтобы вернуть контроль, вводят “шов” (seam): точку расширения, где зависимость можно подменить в тестах. Самый простой путь — вынести доступ к текущему времени в абстракцию и передавать её в код (через конструктор или параметр):
Вот теперь можно написать тесты:
По сути, ты сделал две вещи: (1) отрезал нестабильность (время) от бизнес‑логики, (2) дал тестам рычаг управления этой нестабильностью. Это и есть практический смысл “шва”: не «моки ради моков», а контроль над факторами, которые иначе превращают юнит‑тесты в лотерею.
Создадим основу для тестов
Перед тем как перейти к написанию первых юнит‑тестов, полезно один раз аккуратно подготовить инфраструктуру: завести отдельный тестовый проект, подключить его к основному коду и поставить минимальный набор пакетов. Тогда дальше тесты будут запускаться одинаково у тебя локально, в CI и у коллег, а отчёты по покрытию не потребуют ручных танцев.
Сначала рядом с проектом, который ты собираешься тестировать (например, WebApi), создай каталог tests. Идея простая: исходники лежат в одном месте, тесты — в отдельном, но на том же уровне, чтобы структура решения читалась с первого взгляда. После этого в терминале перейди в tests и создай там новый проект тестов на xUnit:
Дальше тестовому проекту нужно “видеть” код, который он проверяет, поэтому добавь ссылку на основной .csproj (путь подстрой под свою структуру репозитория):
Теперь поставь пакеты, которые закрывают типовые задачи: сам фреймворк тестов и раннер, удобные ассерты, генерацию данных и сбор покрытия. Пример набора (он не единственно верный, но хорошо покрывает базовые потребности):
# фреймворк тестов и раннер
dotnet add WebApi.Tests package xunit
dotnet add WebApi.Tests package xunit.runner.visualstudio
# удобные ассёрты
dotnet add WebApi.Tests package FluentAssertions
# генерация данных / property-based
dotnet add WebApi.Tests package AutoFixture.Xunit2
dotnet add WebApi.Tests package FsCheck.Xunit
# покрытие
dotnet add WebApi.Tests package coverlet.collector
# генератор html-отчётов по покрытию (как dotnet tool)
dotnet tool install -g dotnet-reportgenerator-globaltool
Если в тестах понадобится мокать зависимости (интерфейсы, внешние клиенты, репозитории), можно добавить любой mocking framework, который принят в команде. Например, Moq:
После этого вернись в корень репозитория и подключи тестовый проект к solution, чтобы он запускался вместе с остальными проектами и нормально отображался в IDE:
На этом подготовка закончена — можно писать тесты.
По организации кода обычно удобно придерживаться зеркалирования: тестовый проект повторяет структуру исходников (папки/неймспейсы), чтобы тест к классу находился интуитивно.
Именование тоже лучше стандартизировать: класс тестов называют по схеме {ClassName}Tests, а тест‑методы — {MethodName}_{Scenario}_{ExpectedResult}. Это экономит время при чтении отчётов CI и поиске нужного кейса по названию упавшего теста.
Заключение: как перестать бояться и начать писать юнит‑тесты? И нужно ли?
Бояться юнит‑тестов нормально: чаще всего страх возникает не из-за самого синтаксиса xUnit/NUnit, а из-за ощущения, что ты лезешь «в чужую территорию» — внутрь кода, архитектуры, зависимостей и решений, которые принимал не ты. Но хорошая новость в том, что юнит‑тесты — это не про “уметь программировать как разработчик" (если вы тестировщик) и тестировать как тестировщик” (если вы разработчик). Это про умение зафиксировать контракт поведения и получить быстрый, надёжный сигнал о поломке.
Нужно ли вообще писать юнит‑тесты всем?
Не всегда и не всем. Юнит‑тесты нужны там, где они реально дают окупаемость:
- когда есть чистая логика (парсинг, нормализация, расчёты, правила, валидации, преобразования данных);
- когда изменения частые и риск регрессий высокий;
- когда хочется безопасно рефакторить и не бояться “сломать что-то рядом”.
Но если перед тобой тонкий слой, который в основном прокидывает данные в БД/сеть/фреймворк, и без реальной логики — юниты могут превратиться в дорогую имитацию интеграционных тестов с моками ради моков. В таких местах выгоднее опираться на интеграционные/контрактные проверки и пару сквозных сценариев.
То есть вопрос не “юниты или не юниты”, а какую часть риска они закрывают дешевле всего.
Как перестать бояться: практическая стратегия
Чтобы начать, не нужно ставить цель “покрыть проект”. Нужна цель “получить один стабильный тест, который приносит пользу”.
- Начни с маленьких функций без зависимостей.
Любой helper с предсказуемым входом/выходом — идеальный старт. Там нет моков, нет инфраструктуры, только логика и уверенность. - Тестируй поведение, а не реализацию.
Формулируй ожидания в терминах «что должно получиться», а не «какие методы должны быть вызваны». Тогда тест будет переживать рефакторинг и не станет якорем. - Дроби сценарии.
Один тест — один смысл. Если появляется цикл/условие внутри теста, чаще всего ты склеил несколько кейсов. Раздели или параметризуй. - Если зависимость мешает — не “мочь”, а “прошить шов”.
Время, рандом, статика, внешние клиенты — всё это должно быть под контролем теста через интерфейс/обёртку/инъекцию. Это не «архитектурные изыски», это способ сделать тесты предсказуемыми. - Выбирай “точку максимальной пользы”.
Самый простой критерий: тестируй то, что ломалось раньше или то, что будет меняться завтра. Юнит‑тесты — инструмент снижения стоимости изменений, а не музейный каталог.