Найти в Дзене

Как перестать бояться и начать писать юнит тесты? И нужно ли?

Если вы не работали в IT компании вам вероятно может показаться странным, что наиболее массовый, согласно пирамиде тестирования, вид автотестов - юнит тесты, пишут чаще всего не тестировщики, а разработчики ПО. Вот как выглядит эта пирамида: 1) Юнит‑тест — это про дизайн кода, а не про “проверить фичу”
Юнит тестирует маленькую единицу поведения (функцию/класс/модуль) в изоляции. Чтобы это сделать хорошо, нужно: Это ближе к ежедневной работе разработчика, потому что юнит‑тестирование часто идёт рука об руку с рефакторингом и эволюцией дизайна. 2) Юнит‑тесты максимально дешевы там, где их пишут “сразу”
Разработчик пишет код → тут же пишет тест → тут же получает быстрый фидбек.
Если этот цикл разорван (например, тестировщик пишет юниты “потом” или “параллельно”), то: Итог: писать юнит‑тесты после факта дороже, чем в момент создания. 3) Доступ и ответственность за “внутренности”
Юнит‑тесты часто требуют: Команда обычно “естественным образом” закрепляет это за теми, кто эти внутренности мен
Оглавление

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

Вот как выглядит эта пирамида:

Пирамида тестирования
Пирамида тестирования

Почему так получилось (причины)

1) Юнит‑тест — это про дизайн кода, а не про “проверить фичу”
Юнит тестирует маленькую единицу поведения (функцию/класс/модуль) в изоляции. Чтобы это сделать хорошо, нужно:

  • понимать внутренние инварианты;
  • знать, какие зависимости нужно «отрезать» (mock/stub/fake);
  • иногда слегка менять архитектуру, чтобы код стал тестопригодным (инъекция зависимостей, выделение интерфейсов, разделение ответственности).

Это ближе к ежедневной работе разработчика, потому что юнит‑тестирование часто идёт рука об руку с рефакторингом и эволюцией дизайна.

2) Юнит‑тесты максимально дешевы там, где их пишут “сразу”
Разработчик пишет код → тут же пишет тест → тут же получает быстрый фидбек.
Если этот цикл разорван (например, тестировщик пишет юниты “потом” или “параллельно”), то:

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

Итог: писать юнит‑тесты после факта дороже, чем в момент создания.

3) Доступ и ответственность за “внутренности”
Юнит‑тесты часто требуют:

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

Команда обычно “естественным образом” закрепляет это за теми, кто эти внутренности меняет ежедневно — за разработчиками.

Какие были предпосылки (почему индустрия к этому пришла)

  • Усложнение систем: микросервисы, асинхронщина, интеграции, сложные доменные правила. Чем сложнее логика, тем выгоднее ловить ошибки “на микроскопическом уровне”.
  • CI/CD и скорость поставки: когда релизы частые, без быстрых проверок на уровне кода цикл разработки расползается.
  • Сдвиг QA в сторону системного качества: тестировщик всё чаще закрывает риск‑области там, где юнитами “не достать” — интеграции, контракты, наблюдаемость, тест‑стратегия, критические пользовательские потоки, нефункционал, анализ рисков.

Последствия (хорошие и не очень)

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

Главный плюс в том, что дефекты ловятся максимально рано: разработчик пишет код и тут же проверяет его на уровне маленьких “единиц” логики. Ошибка обнаруживается ещё до того, как она успела расползтись в интеграцию, затронуть соседние модули, данные или окружение. В результате до интеграционных и системных стадий доходит меньше “мелких” и “локальных” проблем, которые иначе съели бы время на разбор, воспроизведение и поиск причины.

Второе важное преимущество — скорость обратной связи в PR. Юнит‑тесты обычно выполняются быстро, а падение теста часто сразу указывает на конкретный метод/класс и конкретное ожидание. Это ускоряет цикл “написал → проверил → поправил”, повышает уверенность в изменениях и делает код‑ревью более предметным: обсуждают не только “как написано”, но и “какое поведение гарантируется”.

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

Наконец, юнит‑тесты сильно облегчают рефакторинг. Если у модуля есть набор проверок поведения, разработчик может смелее менять внутреннюю структуру кода, не опасаясь случайно сломать важную логику: тесты выступают страховочной сеткой. Это повышает “поддерживаемость” кода в долгую — меньше страха перед изменениями, меньше накопления техдолга.

Но у этой картины есть и обратная сторона. Когда метрики (например, процент покрытия) становятся целью сами по себе, появляется соблазн писать тесты “для галочки”: тестировать очевидное, дублировать реализацию, покрывать строки, а не смыслы. Такие тесты создают иллюзию надёжности — отчёт красивый, а реальные риски могут оставаться нетронутыми.

Ещё один частый минус — чрезмерная привязка тестов к реализации. Если тест проверяет не внешнее поведение (контракт), а внутренние детали (какие методы вызвались, в каком порядке, какие именно приватные структуры изменились), то любой рефакторинг превращается в “ремонт тестов”. Это делает тесты хрупкими и вызывает раздражение: вместо ускорения разработки они начинают её тормозить.

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

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

Как перестать бояться и начать писать юнит тесты?

Так как же все-таки приступить к написанию юнит тестов?

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

Первое - определиться, что должны проверять юнит тесты.

Что тестировать юнит-тестами:

1. Бизнес-логику

Если метод что-то считает, преобразует, фильтрует, сортирует — это твоя цель.

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

2. Валидацию входных данных

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

3. Пограничные случаи

Минимумы, максимумы, пустые коллекции, null — всё, что может сломать логику.

4. Поведение при разных условиях

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

5. Контракты и интерфейсы

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

Что не стоит тестировать юнит-тестами:

  • Базы данных — это уже интеграционные тесты.
  • HTTP-запросы — тоже не сюда, если только ты не мокируешь клиента.
  • UI/вёрстку — это для e2e или UI-тестов.
  • Логирование, трекинг, метрики — если только не критично для логики.

Что НЕ тестируют юнит-тесты:

  1. Интеграцию и взаимодействие между разными модулями, сервисами, базами данных, файловой системой или внешними API.
  2. Производительность, нагрузку и масштабируемость; для этого нужны другие виды тестирования (нагрузочное, стресс-тестирование).
  3. Бизнес-логику в контексте пользователя (user story); они проверяют как код работает, а не что он должен делать для пользователя.
  4. Нефункциональные требования: безопасность, отказоустойчивость, юзабилити, совместимость.
  5. Приватные методы напрямую; их логика проверяется через публичные методы, чтобы избежать привязки тестов к деталям реализации.
  6. Очевидное поведение; если что-то очевидно, тратить на это тесты неэффективно, лучше сосредоточиться на неочевидных сценариях.
  7. Системные ошибки в целом; они видны только на этапе интеграционного или системного тестирования.

Характеристики хороших модульных тестов

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

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

Стандарты именования Unit тестов

Имя теста должно состоять из трех частей:

  • Имя проверяемого метода
  • Сценарий, в котором тестируется метод
  • Ожидаемое поведение при вызове сценария
-2

Структура теста (AAA)

Подготовка - Действие - Проверка

-3

Избегайте логики написания кода в модульных тестах

Юнит‑тесты должны быть максимально простыми: «подготовил входные данные → вызвал метод → проверил результат». Как только в тесте появляется “мини‑программа” с циклами, ветвлениями и ручной сборкой строк, тест начинает жить собственной жизнью — и ты внезапно получаешь второй кусок логики, который тоже может ошибаться. В итоге падение теста перестаёт однозначно означать «сломался продуктовый код»: иногда ломается сам тест, потому что его логика неверна или стала неактуальной.

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

Плохой пример (тест скрывает несколько ожиданий и копит логику внутри себя):

-4

Хороший пример (каждый кейс прозрачен: вход → ожидаемое поведение, без «программирования в тесте»):

-5

Не тестируйте приватные методы

Частая ловушка при первых шагах в юнит‑тестах — начать “прицельно” тестировать приватные хелперы, потому что они кажутся наиболее понятными и изолированными. Проблема в том, что приватные методы — это деталь реализации. Ты не обещаешь их поведение внешнему миру, ты обещаешь поведение публичного API. Если привязаться тестами к приватной структуре, то любой рефакторинг (даже безопасный) превращается в переписывание тестов, а не в улучшение кода.

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

-6

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

-7

Обработка статических зависимостей через “швы”

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

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

-8

Проблема очевидна: тест “на утро” будет то зелёным, то красным — зависит от времени запуска.

Чтобы вернуть контроль, вводят “шов” (seam): точку расширения, где зависимость можно подменить в тестах. Самый простой путь — вынести доступ к текущему времени в абстракцию и передавать её в код (через конструктор или параметр):

-9

Вот теперь можно написать тесты:

-10

По сути, ты сделал две вещи: (1) отрезал нестабильность (время) от бизнес‑логики, (2) дал тестам рычаг управления этой нестабильностью. Это и есть практический смысл “шва”: не «моки ради моков», а контроль над факторами, которые иначе превращают юнит‑тесты в лотерею.

Создадим основу для тестов

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

Сначала рядом с проектом, который ты собираешься тестировать (например, WebApi), создай каталог tests. Идея простая: исходники лежат в одном месте, тесты — в отдельном, но на том же уровне, чтобы структура решения читалась с первого взгляда. После этого в терминале перейди в tests и создай там новый проект тестов на xUnit:

-11

Дальше тестовому проекту нужно “видеть” код, который он проверяет, поэтому добавь ссылку на основной .csproj (путь подстрой под свою структуру репозитория):

-12

Теперь поставь пакеты, которые закрывают типовые задачи: сам фреймворк тестов и раннер, удобные ассерты, генерацию данных и сбор покрытия. Пример набора (он не единственно верный, но хорошо покрывает базовые потребности):

# фреймворк тестов и раннер
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:

-13

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

-14

На этом подготовка закончена — можно писать тесты.

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

Именование тоже лучше стандартизировать: класс тестов называют по схеме {ClassName}Tests, а тест‑методы — {MethodName}_{Scenario}_{ExpectedResult}. Это экономит время при чтении отчётов CI и поиске нужного кейса по названию упавшего теста.

Заключение: как перестать бояться и начать писать юнит‑тесты? И нужно ли?

Бояться юнит‑тестов нормально: чаще всего страх возникает не из-за самого синтаксиса xUnit/NUnit, а из-за ощущения, что ты лезешь «в чужую территорию» — внутрь кода, архитектуры, зависимостей и решений, которые принимал не ты. Но хорошая новость в том, что юнит‑тесты — это не про “уметь программировать как разработчик" (если вы тестировщик) и тестировать как тестировщик” (если вы разработчик). Это про умение зафиксировать контракт поведения и получить быстрый, надёжный сигнал о поломке.

Нужно ли вообще писать юнит‑тесты всем?

Не всегда и не всем. Юнит‑тесты нужны там, где они реально дают окупаемость:

  • когда есть чистая логика (парсинг, нормализация, расчёты, правила, валидации, преобразования данных);
  • когда изменения частые и риск регрессий высокий;
  • когда хочется безопасно рефакторить и не бояться “сломать что-то рядом”.

Но если перед тобой тонкий слой, который в основном прокидывает данные в БД/сеть/фреймворк, и без реальной логики — юниты могут превратиться в дорогую имитацию интеграционных тестов с моками ради моков. В таких местах выгоднее опираться на интеграционные/контрактные проверки и пару сквозных сценариев.

То есть вопрос не “юниты или не юниты”, а какую часть риска они закрывают дешевле всего.

Как перестать бояться: практическая стратегия

Чтобы начать, не нужно ставить цель “покрыть проект”. Нужна цель “получить один стабильный тест, который приносит пользу”.

  1. Начни с маленьких функций без зависимостей.
    Любой helper с предсказуемым входом/выходом — идеальный старт. Там нет моков, нет инфраструктуры, только логика и уверенность.
  2. Тестируй поведение, а не реализацию.
    Формулируй ожидания в терминах «что должно получиться», а не «какие методы должны быть вызваны». Тогда тест будет переживать рефакторинг и не станет якорем.
  3. Дроби сценарии.
    Один тест — один смысл. Если появляется цикл/условие внутри теста, чаще всего ты склеил несколько кейсов. Раздели или параметризуй.
  4. Если зависимость мешает — не “мочь”, а “прошить шов”.
    Время, рандом, статика, внешние клиенты — всё это должно быть под контролем теста через интерфейс/обёртку/инъекцию. Это не «архитектурные изыски», это способ сделать тесты предсказуемыми.
  5. Выбирай “точку максимальной пользы”.
    Самый простой критерий: тестируй то, что ломалось раньше или то, что будет меняться завтра. Юнит‑тесты — инструмент снижения стоимости изменений, а не музейный каталог.
-15