Тестирование ПО Модульные тесты: изоляция, тестовые дублёры, что такое хороший юнит-тест
0%

Модульные тесты: изоляция, тестовые дублёры, что такое хороший юнит-тест

Модульные тесты: изоляция, тестовые дублёры, что такое хороший юнит-тест

Юнит-тесты — единственный вид тестов, который пишут почти все и который почти все пишут плохо. Плохо не потому, что не умеют вызывать 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):

  1. Защита от багов — вероятность, что тест упадёт, если в коде появится настоящая ошибка.
  2. Устойчивость к рефакторингу — вероятность, что тест НЕ упадёт, если поведение сохранено, а код переписан.
  3. Быстрая обратная связь — скорость выполнения.
  4. Простота поддержки — насколько легко тест читать и чинить.

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

Ложное срабатывание (тест упал, а баг не появился) вреднее пропуска. Пропущенный баг стоит один инцидент. Регулярные ложные срабатывания стоят доверия ко всему набору: после третьего «просто перезапусти, оно иногда красное» команда перестаёт читать отчёты, и набор превращается в дорогой генератор шума. Тест, который никто не читает, имеет отрицательную ценность: за него платят временем поддержки, а информации он не даёт.

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

Что делать вместо погони за процентом:

  1. Не ставьте порог покрытия целью. Закон Гудхарта: как только метрика становится целью, она перестаёт быть метрикой. Порог 80% в quality gate гарантированно порождает тесты на геттеры.
  2. Используйте покрытие как детектор дыр, а не как оценку. Полезный вопрос не «сколько процентов», а «какие важные модули имеют покрытие сильно ниже среднего». Смотрите на дифф-покрытие нового кода в PR — это единственное применение метрики, которое реально работает.
  3. Проверяйте качество тестов мутационным тестированием. Инструмент вносит в код мелкие мутации (> на >=, + на -, 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, где часть флаков неустранима принципиально.

Чек-лист причин в порядке частоты:

  1. Порядок выполнения — общий модульный кэш, синглтон, не сброшенный dict на уровне класса.
  2. Реальное времяdatetime.now() внутри логики, тест падает раз в сутки на границе полуночи или в конце месяца.
  3. Порядок в коллекциях — сравнение списка там, где по смыслу множество; в Python поведение set и хешей стабильно в рамках запуска, но не между запусками при PYTHONHASHSEED=random.
  4. Плавающая точкаassert 0.1 + 0.2 == 0.3. Используйте pytest.approx / toBeCloseTo.
  5. Реальная конкурентность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, форматов сообщений, конфигурации. Следующий шаг — уровень, где всё это становится видимым.

Интеграционное тестирование, тестовые контейнеры и контрактные тесты

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов