Модульные тесты: изоляция, тестовые дублёры, что такое хороший юнит-тест
Юнит-тесты — единственный вид тестов, который пишут почти все и который почти все пишут плохо. Плохо не потому,
что не умеют вызывать assert, а потому что не отвечают заранее на два вопроса: что здесь единица и
что именно мы фиксируем как контракт. Ответы на них определяют, станет ли набор тестов опорой при рефакторинге
или бетонным блоком, привязанным к ноге.
В предыдущих статьях трека мы разбирали, как решать, что тестировать вообще (тест-дизайн) и как оформлять результаты (документация). Здесь спускаемся на самый нижний уровень пирамиды — туда, где обратная связь измеряется миллисекундами и где закладывается или разрушается способность команды менять код без страха.
Сразу о расхождении взглядов. Для разработчика юнит-тест — это инструмент проектирования и страховка при изменении собственного кода; он пишет его для себя и имеет право знать внутренности. Для тестировщика юнит-тесты — это чужая территория, в которую он обычно не пишет, но обязан уметь читать: по ним видно, какие сценарии разработчик считает важными, а какие целые классы риска остались непокрытыми и должны быть добраны на уровне интеграции и E2E. Эти две позиции будут расходиться по ходу всей статьи, и я буду отмечать где.
1. Что такое «юнит» и почему спор о его размере тупиковый
Каноническое заблуждение: юнит — это класс или метод. Из него растёт правило «один тестовый класс на один продуктовый класс», а из правила — армия моков, потому что если тестируемая единица это ровно один класс, то все соседи обязаны быть подменены.
Более рабочее определение, к которому независимо пришли Кент Бек, Мартин Фаулер и Владимир Хориков:
Юнит — это единица поведения, а не единица кода. Тестируется наблюдаемое поведение, доступное через публичный API модуля; сколько классов участвует внутри — вопрос реализации.
Разница практическая, а не философская. Возьмём расчёт цены заказа: сервис OrderPricer, объект-значение Money,
набор правил TaxRules. Если юнит — это класс, тест на OrderPricer подменяет TaxRules моком, и любое
перекладывание логики между двумя классами ломает тесты, хотя поведение системы не изменилось. Если юнит — это
поведение «цена заказа считается правильно», тест использует настоящие Money и TaxRules, а переезд логики
между ними тесты не замечает. Второй набор тестов переживёт рефакторинг, первый — нет.
Практический критерий границы: всё, что работает быстро и детерминированно внутри процесса, оставляем настоящим; подменяем только то, что выходит за процесс или вносит недетерминизм. Всё остальное — вопрос вкуса, а не принципа.
2. Экономика: зачем вообще этот уровень
В обзоре трека тестирование определялось как покупка информации о риске. У юнит-тестов уникальная позиция по соотношению цены и скорости:
| Свойство | Юнит | Интеграционный | E2E |
|---|---|---|---|
| Время одного теста | 0.1–10 мс | 50–2000 мс | 5–120 с |
| Локализация дефекта | точная, до функции | до модуля/связки | «где-то сломалось» |
| Стабильность | высокая, если нет IO | средняя | низкая, флаки постоянны |
| Что ловит | логические ошибки, границы | ошибки контрактов и конфигурации | ошибки сборки системы целиком |
| Чего не ловит | всё, что про взаимодействие | всё, что про реального пользователя | — |
Ключевая цифра — не «сколько тестов», а время полного прогона. Набор, который выполняется за 3 секунды,
разработчик запускает после каждого сохранения файла и получает обратную связь до того, как забудет контекст.
Набор на 4 минуты запускается только в CI, обратная связь приходит через полчаса, и цена ошибки вырастает
на порядок. Поэтому дисциплина «в юните нет ни сети, ни файловой системы, ни sleep» — не эстетика,
а защита экономики цикла.
И обратная сторона, о которой редко говорят честно: юнит-тесты не ловят большинство продакшн-инцидентов. Реальные падения — это неверная конфигурация, несовпадение схемы, таймауты, гонки, забытая миграция, неправильный формат даты на границе двух сервисов. Ни один из этих классов дефектов не виден на уровне юнита. Юнит-тесты защищают не от инцидентов, а от регрессий в логике при изменении кода. Это очень ценно и это не то же самое.
3. Четыре свойства хорошего юнит-теста
Самая полезная модель для оценки конкретного теста — четыре характеристики из книги Владимира Хорикова «Unit Testing: Principles, Practices, and Patterns» (manning.com):
- Защита от багов — вероятность, что тест упадёт, если в коде появится настоящая ошибка.
- Устойчивость к рефакторингу — вероятность, что тест НЕ упадёт, если поведение сохранено, а код переписан.
- Быстрая обратная связь — скорость выполнения.
- Простота поддержки — насколько легко тест читать и чинить.
Первые три конфликтуют между собой, и это математически неустранимо: усиливая любые две, вы теряете третью.
Ложное срабатывание (тест упал, а баг не появился) вреднее пропуска. Пропущенный баг стоит один инцидент. Регулярные ложные срабатывания стоят доверия ко всему набору: после третьего «просто перезапусти, оно иногда красное» команда перестаёт читать отчёты, и набор превращается в дорогой генератор шума. Тест, который никто не читает, имеет отрицательную ценность: за него платят временем поддержки, а информации он не даёт.
4. Анатомия теста: AAA и одна причина падения
# pytest, Python 3.12
def test_скидка_не_применяется_к_заказу_ниже_порога() -> None:
# Arrange — готовим состояние, никаких проверок
pricer = OrderPricer(threshold=Money("1000.00"), discount_percent=10)
order = Order(items=[Item(price=Money("999.99"), qty=1)])
# Act — ровно одно действие, ради которого написан тест
total = pricer.total(order)
# Assert — проверяем результат, а не путь к нему
assert total == Money("999.99")
Правила, которые окупаются:
- Одна секция Act. Если действий два, тест проверяет сценарий, а не единицу поведения — это уже интеграционный.
- Имя описывает поведение, а не метод.
test_totalне говорит ничего;test_скидка_не_применяется_ниже_порогапри падении в CI сразу сообщает, что сломалось. Русские имена в тестах — легальный и полезный приём для доменной логики; в библиотечном коде лучше английские, но конкретика важнее языка. - Никаких
if, циклов и вычислений ожидаемого значения в тесте. Если ожидание считается той же формулой, что и продукт, тест проверяет, что формула равна себе. Ожидаемое значение пишется константой, посчитанной руками. - Не более одной концептуальной проверки. Три
assertна разные поля одного результата — нормально; триassertна три разных поведения — три теста, иначе первое падение маскирует остальные.
Плохой пример, который встречается в каждом втором проекте:
def test_расчёт() -> None:
expected = order.subtotal * (1 - DISCOUNT / 100) # та же формула, что в продукте
assert pricer.total(order) == expected # тест не упадёт даже при неверной формуле
5. Наблюдаемое поведение против деталей реализации
Тест обязан знать о SUT ровно столько, сколько знает его клиент. Всё, что сверх этого, — будущие ложные срабатывания.
Практический фильтр: если, чтобы написать assert, вам пришлось расширить публичный API или залезть в приватное поле, — вы тестируете не то. Классические симптомы:
- проверка приватных методов через рефлексию или через
_метод; - геттер, добавленный в класс только ради теста;
- проверка порядка и количества вызовов внутренних соседей;
- сравнение с точной строкой лога.
Тестировщику здесь важно другое: по такому тесту нельзя понять требование. Тест на приватный метод не отвечает на вопрос «что обещано пользователю», а значит, его нельзя использовать как источник знаний о системе. Тест, названный по поведению и проверяющий публичный контракт, работает ещё и как исполняемая документация — это одна из главных причин, по которым тестировщику стоит уметь читать чужие юнит-тесты.
6. Изоляция: две несовместимые школы
Слово «изоляция» означает две разные вещи, и почти все споры про моки — это спор людей, использующих его в разных смыслах.
Спор давно решён практикой: классическая школа выигрывает по совокупности, потому что устойчивость к рефакторингу — свойство, которое нельзя добрать ничем другим, а размытая локализация лечится дёшево (упавшие тесты почти всегда указывают на общий модуль, и стек-трейс на месте). Мартин Фаулер разобрал этот водораздел ещё в 2007 году в «Mocks Aren’t Stubs» (martinfowler.com) — статья до сих пор актуальна дословно.
Отдельная беда мокистского подхода: набор тестов перестаёт замечать интеграционные дефекты внутри системы.
Если A мокает B, а тесты B проверяют B изолированно, то расхождение в ожиданиях между ними не увидит никто.
Мок фиксирует ваше представление о соседе, а не его настоящее поведение — и когда сосед меняется, мок продолжает
врать зелёным. Это главный аргумент, почему контрактные тесты нужны отдельно (о них — в
статье про интеграцию).
7. Пять тестовых дублёров: словарь, который стоит выучить
Терминология Джерарда Мейсароса из «xUnit Test Patterns» (xunitpatterns.com). В речи все пять называют «моками», и это источник половины недопонимания на код-ревью.
Главное различие проходит не по технике, а по направлению взаимодействия:
- Stub и fake отвечают на входящие взаимодействия — SUT спрашивает, дублёр отвечает. Такое взаимодействие не является частью наблюдаемого поведения SUT: клиенту всё равно, сколько раз сервис спросил у репозитория. Проверять вызовы стаба — антипаттерн.
- Mock и spy фиксируют исходящие взаимодействия — SUT сообщает что-то внешнему миру. Отправка письма, списание денег, публикация события в шину — это и есть наблюдаемое поведение, и его исчезновение обязано ронять тест.
Код: те же дублёры на Python и TypeScript
from dataclasses import dataclass
from unittest.mock import Mock
# --- Fake: рабочая реализация в памяти, лучший дублёр для своего хранилища ---
class InMemoryUserRepo:
def __init__(self, users: dict[str, "User"] | None = None) -> None:
self._users = users or {}
def find(self, user_id: str) -> "User | None":
return self._users.get(user_id)
def save(self, user: "User") -> None:
self._users[user.id] = user
# --- Stub на минималках: не нужна библиотека, достаточно класса ---
@dataclass
class StubExchangeRates:
rate: float
def usd_to_rub(self, amount: float) -> float:
return amount * self.rate
def test_приветственное_письмо_уходит_один_раз() -> None:
# Arrange
repo = InMemoryUserRepo({"u1": User(id="u1", email="a@b.c")})
email = Mock(spec=EmailGateway) # мок только для внешней системы
service = NotifyService(repo, email)
# Act
service.welcome("u1")
# Assert — проверяем ровно факт исходящего сообщения
email.send.assert_called_once_with("a@b.c", template="welcome")
# и НЕ проверяем, сколько раз сервис ходил в repo — это деталь реализации
// vitest
import { describe, it, expect, vi } from "vitest";
class InMemoryUserRepo implements UserRepo {
constructor(private users = new Map<string, User>()) {}
async find(id: string): Promise<User | null> {
return this.users.get(id) ?? null;
}
}
describe("NotifyService", () => {
it("отправляет приветственное письмо ровно один раз", async () => {
const repo = new InMemoryUserRepo(new Map([["u1", { id: "u1", email: "a@b.c" }]]));
// мок внешнего шлюза: исходящее взаимодействие — часть контракта
const gateway = { send: vi.fn().mockResolvedValue(undefined) };
await new NotifyService(repo, gateway).welcome("u1");
expect(gateway.send).toHaveBeenCalledExactlyOnceWith("a@b.c", "welcome");
});
});
Обратите внимание на spec=EmailGateway в Python: без него Mock радостно проглотит опечатку в имени метода
и тест останется зелёным при полностью сломанном коде. Всегда указывайте spec / autospec — это одна строка,
которая закрывает целый класс молчаливых ложных «зелёных». В TypeScript ту же роль играет типизация мока
через интерфейс, а не через any.
8. Правило решения: мокать или не мокать
Разделение на управляемые и неуправляемые зависимости — самый практичный из известных мне критериев. Своя база — деталь реализации: её схему можно менять, и тесты не должны об этом знать. Чужое API — часть наблюдаемого контракта: если вы перестали дёргать платёжный шлюз, это баг, и мок обязан его поймать.
Диагностический признак плохого дизайна. Если для теста одного метода нужно четыре мока — проблема не в тестах. Класс с четырьмя внешними зависимостями смешивает решение и выполнение. Лечение — вынести чистую логику в функции без зависимостей и оставить тонкий слой, который только оркестрирует. Это архитектура «функциональное ядро, императивная оболочка»:
# Было: логика и IO перемешаны, тестируется только через моки
class ReportService:
def build(self, user_id: str) -> None:
rows = self.db.query(...) # IO
total = sum(r.amount for r in rows if r.ok) # логика, ради которой всё
self.storage.put(f"{user_id}.csv", render(total)) # IO
self.mailer.send(user_id, "готово") # IO
# Стало: ядро — чистая функция, тестируется без единого мока
def summarize(rows: list[Row]) -> Summary: # <- сюда все граничные случаи, 20 быстрых тестов
...
class ReportService: # <- тонкая оболочка, 1-2 теста на связывание
def build(self, user_id: str) -> None:
summary = summarize(self.db.query(...))
self.storage.put(f"{user_id}.csv", render(summary))
self.mailer.send(user_id, "готово")
Такой сдвиг обычно уменьшает число моков в проекте в разы и одновременно повышает покрытие логики. Подробнее о разделении эффектов — в треке функционального программирования.
9. Недетерминизм: время, случайность, идентификаторы
Три источника флаки-тестов на юнит-уровне, и все три лечатся одинаково — инъекцией вместо глобального вызова.
from datetime import datetime, timezone
from typing import Protocol
class Clock(Protocol):
def now(self) -> datetime: ...
class SystemClock:
def now(self) -> datetime:
return datetime.now(timezone.utc)
class FixedClock:
def __init__(self, moment: datetime) -> None:
self._moment = moment
def now(self) -> datetime:
return self._moment
def test_подписка_истекает_ровно_в_полночь_по_utc() -> None:
clock = FixedClock(datetime(2026, 3, 1, 0, 0, 0, tzinfo=timezone.utc))
sub = Subscription(expires_at=datetime(2026, 3, 1, 0, 0, 0, tzinfo=timezone.utc))
assert sub.is_expired(clock) is True # граница: истёкшая ровно в момент
Подмена глобальных часов патчем (freezegun, vi.setSystemTime) работает, но патч — это неявная зависимость:
он ломается при смене библиотеки и не виден в сигнатуре. Явный Clock в конструкторе дороже на одну строку кода
и дешевле на несколько часов отладки. То же для random (передавайте Random(seed)) и для генерации UUID
(передавайте фабрику).
Отдельно про часовые пояса — источник дефектов, который юнит-тесты ловят прекрасно, если о нём вспомнить:
прогоняйте набор с TZ=Pacific/Kiritimati (UTC+14) хотя бы в ночной сборке. Половина проектов краснеет
на первом же запуске.
10. Параметризация и property-based тестирование
Классы эквивалентности и границы из статьи про тест-дизайн ложатся на код один в один — через параметризацию:
import pytest
@pytest.mark.parametrize(
"amount, expected_percent",
[
(999, 0), # чуть ниже границы
(1000, 5), # ровно граница
(1001, 5), # чуть выше
(4999, 5),
(5000, 10), # вторая граница
(0, 0), # вырожденный случай
],
)
def test_ступени_скидки(amount: int, expected_percent: int) -> None:
assert discount_percent(Money(amount)) == expected_percent
Шесть строк вместо шести тестов, и при добавлении ступени правится одна таблица. Но параметризация проверяет только те точки, о которых вы догадались. Там, где вход богатый, лучше работает property-based тестирование: вы формулируете инвариант, а библиотека сама ищет контрпример и минимизирует его.
from hypothesis import given, strategies as st
@given(st.lists(st.integers(min_value=0, max_value=10**6), max_size=50))
def test_итог_не_меньше_максимального_элемента(prices: list[int]) -> None:
# инвариант, который обязан держаться при любом наборе позиций
assert cart_total(prices) >= (max(prices) if prices else 0)
@given(st.text())
def test_нормализация_идемпотентна(raw: str) -> None:
# свойство: повторное применение ничего не меняет
assert normalize(normalize(raw)) == normalize(raw)
import fc from "fast-check";
import { test, expect } from "vitest";
test("сериализация и разбор возвращают исходный объект", () => {
fc.assert(
fc.property(fc.record({ id: fc.uuid(), qty: fc.integer({ min: 1, max: 999 }) }), (item) => {
expect(parseItem(serializeItem(item))).toEqual(item);
}),
);
});
Три инварианта, которые почти всегда есть и почти никогда не проверяются: round-trip (сериализация/разбор),
идемпотентность (повторное применение), коммутативность или порядок (результат не зависит от порядка входов).
Property-based тест на round-trip находит за минуту то, что примерами ищут неделю: Unicode-сюрпризы, пустые строки,
переполнения, -0, NaN. Документация: Hypothesis,
fast-check.
Здесь взгляды профессий сходятся приятнее всего: property-based тест — это, по сути, формализованное исследовательское тестирование, и тестировщик обычно формулирует инварианты лучше разработчика, потому что думает о системе снаружи.
11. Антипаттерны, которые дороже отсутствия тестов
Mystery Guest. Тест зависит от данных, которых в нём не видно: фикстура на 200 строк, дамп базы, файл
testdata/order.json. Прочитать тест и понять, почему ожидается 1200, невозможно. Лечение — Object Mother
или билдер с говорящими умолчаниями:
def an_order(*, items: list[Item] | None = None, vip: bool = False) -> Order:
"""Всё, что важно для теста, задаётся явно; остальное — разумные умолчания."""
return Order(items=items or [Item(price=Money(100), qty=1)], vip=vip)
def test_vip_получает_дополнительные_5_процентов() -> None:
assert pricer.total(an_order(vip=True)) == Money("95.00") # видно ровно то, что важно
Общее изменяемое состояние между тестами. Модульный кэш, синглтон, класс-уровневая фикстура. Симптом:
тесты зелёные по одному и красные в наборе, или падают при pytest -p no:randomly иначе, чем при случайном порядке.
Обязательно ставьте pytest-randomly или --shuffle в vitest: случайный порядок вскрывает зависимости между
тестами сразу, а не через полгода.
Тестирование мока. mock.assert_called_once() там, где стаб просто отдал данные. Такой assert не проверяет
ничего о продукте — он проверяет, что вы правильно настроили мок.
Логика в тесте. if, for, вычисление ожидаемого. Тест должен быть настолько тупым, чтобы его правильность
была очевидна без отладки. Тест, который надо отлаживать, — это второй продукт без тестов.
Снапшот вместо утверждения. toMatchSnapshot() на большом объекте: при первом падении разработчик нажимает
-u, и снапшот фиксирует баг как норму. Снапшоты уместны для действительно стабильного вывода и вредны для
бизнес-логики.
Тесты «на всякий случай» для тривиального кода. Тест на геттер, на __str__, на маппинг DTO без логики.
Защиты от багов нет, стоимость поддержки есть, а процент покрытия растёт — из-за чего эти тесты и пишут.
12. Покрытие: самая честная и самая бесполезная метрика
Покрытие измеряет, какой код исполнился во время прогона. Оно не измеряет, что этот код был проверен. Разница видна на трёх строках:
def test_ничего_не_проверяющий() -> None:
pricer.total(an_order()) # 100% покрытия функции total, ноль assert-ов
Строчное покрытие 100%, защита от багов — нулевая. Более честная метрика — branch coverage (покрытие ветвей), но и она обманывается:
def discount(amount: int, vip: bool) -> int:
if amount > 1000 and vip: # branch coverage закрывается двумя тестами
return 10 # но комбинация (amount<=1000, vip=True) не проверена
return 0
Что делать вместо погони за процентом:
- Не ставьте порог покрытия целью. Закон Гудхарта: как только метрика становится целью, она перестаёт быть метрикой. Порог 80% в quality gate гарантированно порождает тесты на геттеры.
- Используйте покрытие как детектор дыр, а не как оценку. Полезный вопрос не «сколько процентов», а «какие важные модули имеют покрытие сильно ниже среднего». Смотрите на дифф-покрытие нового кода в PR — это единственное применение метрики, которое реально работает.
- Проверяйте качество тестов мутационным тестированием. Инструмент вносит в код мелкие мутации
(
>на>=,+на-,return Trueнаreturn False) и смотрит, упал ли хоть один тест. Не упал — мутация «выжила», значит это место покрыто формально, а проверено никак.
# Python: mutmut
pip install mutmut && mutmut run --paths-to-mutate src/pricing/
mutmut results # список выживших мутантов = список дыр в тестах
# JS/TS: Stryker
npx stryker run # выдаёт mutation score — процент убитых мутантов
Мутационное тестирование медленное (прогон набора на каждую мутацию), поэтому его гоняют не на каждый PR, а раз в неделю по критическим модулям. Но первый же запуск на «модуле со 100% покрытия» обычно даёт mutation score около 60% — и это самый отрезвляющий отчёт, который команда видит за квартал. Материалы: PIT (pitest.org), Stryker (stryker-mutator.io).
Расхождение взглядов. Разработчик обычно защищает покрытие как понятную цифру для отчёта. Тестировщик должен задавать неудобный вопрос: «покажи мутационный score или назови, какие сценарии из чек-листа эти тесты закрывают». Процент строк не отвечает ни на один вопрос о риске — а именно риск, а не строки, интересует бизнес (см. принципы и терминологию).
13. Флаки-тесты на уровне юнита
Считается, что флаки бывают только в E2E. На самом деле в юнитах их тоже хватает, просто причины другие и все они устранимы полностью — в отличие от E2E, где часть флаков неустранима принципиально.
Чек-лист причин в порядке частоты:
- Порядок выполнения — общий модульный кэш, синглтон, не сброшенный
dictна уровне класса. - Реальное время —
datetime.now()внутри логики, тест падает раз в сутки на границе полуночи или в конце месяца. - Порядок в коллекциях — сравнение списка там, где по смыслу множество; в Python поведение
setи хешей стабильно в рамках запуска, но не между запусками приPYTHONHASHSEED=random. - Плавающая точка —
assert 0.1 + 0.2 == 0.3. Используйтеpytest.approx/toBeCloseTo. - Реальная конкурентность —
sleep(0.1)в ожидании потока. Заменяйте на явную синхронизацию или на детерминированный планировщик.
Политика по флакам: красный флаки-тест — это баг с приоритетом, а не повод для retry. Массовое включение
ретраев на уровне раннера — самое дорогое решение из возможных: оно прячет и настоящие гонки в продуктовом коде.
Подробнее про карантин и статистику флаков — в статье про тесты в CI.
14. Как это выглядит в проде
Минимальная конфигурация, которая закрывает большинство описанного:
# .github/workflows/unit.yml
name: unit
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
env:
TZ: Pacific/Kiritimati # UTC+14: ловим ошибки часовых поясов
PYTHONHASHSEED: random # ловим неявную зависимость от порядка хешей
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements-dev.txt
- name: Прогон юнит-тестов
run: |
pytest tests/unit \
-p randomly \
--timeout=5 \
--cov=src --cov-report=xml \
--cov-fail-under=0 # порога нет намеренно: смотрим дифф-покрытие
- name: Дифф-покрытие нового кода
run: diff-cover coverage.xml --compare-branch=origin/main --fail-under=80
Три решения в этом конфиге, которые стоит скопировать: случайный порядок тестов, жёсткий таймаут на тест (любой юнит-тест дольше 5 секунд — не юнит-тест, и это должно падать), порог только на новый код, а не на весь репозиторий.
Чек-лист код-ревью юнит-теста
Работает и для разработчика, и для тестировщика, который читает чужие тесты:
- По имени теста понятно, какое поведение проверяется, без чтения тела.
- Есть ровно один Act; ожидаемое значение записано константой, а не вычислено формулой продукта.
- Все данные, влияющие на результат, видны в теле теста; фикстуры не прячут важное.
- Моки стоят только на неуправляемых зависимостях; проверок вызовов стабов нет.
- У всех моков задан
spec/ строгая типизация. - Нет обращения к приватным членам, нет геттеров, добавленных ради теста.
- Тест не зависит от реального времени, случайности, порядка выполнения и локали.
- Проверены границы, а не только «счастливый путь»; есть хотя бы один негативный случай.
- Тест упадёт, если сломать проверяемое поведение — проверьте это, временно сломав код.
Последний пункт — самый недооценённый. Одноразовая проверка «сломай продукт, убедись, что тест покраснел» занимает тридцать секунд и заменяет собой все рассуждения о качестве теста. Это ручная мутация, доступная всегда.
15. Мини-итог
- Юнит — это единица поведения, а не класс. Граница проходит там, где кончается быстрый детерминированный код.
- Хороший тест балансирует четыре свойства, и главный дефицит — устойчивость к рефакторингу: её нельзя добрать ничем, а теряется она от привязки к деталям реализации.
- Дублёров пять, различаются они направлением взаимодействия: stub и fake отвечают, mock и spy проверяют. Проверять входящие взаимодействия — антипаттерн.
- Мокайте неуправляемые зависимости; свою базу подменяйте fake ради скорости, но не проверяйте вызовы.
- Много моков — сигнал плохого дизайна, а не сложного домена. Выносите чистое ядро.
- Покрытие показывает исполненный код, а не проверенный. Работают дифф-покрытие и мутационное тестирование.
- Флаки на юнит-уровне устранимы полностью: состояние, время, порядок, конкурентность.
Источники, которые стоят полного прочтения: Владимир Хориков «Unit Testing: Principles, Practices, and Patterns» (manning.com); Джерард Мейсарос «xUnit Test Patterns» (xunitpatterns.com); Мартин Фаулер, «Mocks Aren’t Stubs» (martinfowler.com) и «Test Pyramid» (martinfowler.com); Майкл Физерс «Working Effectively with Legacy Code» — про швы и внедрение тестов в код, который их не ждал.
Что дальше
Юнит-тесты проверяют логику в изоляции и по построению не видят самого частого источника продакшн-дефектов — стыков между компонентами: схем БД, контрактов HTTP, форматов сообщений, конфигурации. Следующий шаг — уровень, где всё это становится видимым.
Интеграционное тестирование, тестовые контейнеры и контрактные тесты