Принципы тестирования: пирамида, TDD, BDD, свойства хорошего теста
Спросите десять разработчиков, зачем нужны тесты, и девять ответят «чтобы находить баги». Это правда, но это самая скучная и наименее ценная часть правды. Ручное тестирование находит баги лучше автотестов — человек замечает то, что никто не догадался закодировать в ассерт. Автотесты выигрывают в другом: они находят старые баги, которые вы вносите прямо сейчас, меняя работающий код.
Настоящая функция набора тестов — сделать изменение дешёвым. В статье о
запахах кода и рефакторинге мы говорили,
что рефакторинг определяется через сохранение поведения. «Сохранение» кто-то должен подтвердить —
иначе это не рефакторинг, а надежда. Тесты — тот самый механизм подтверждения. Без них любая
структурная правка превращается в лотерею, команда начинает бояться кода, а код начинает гнить:
проще написать ещё один if, чем тронуть то, что работает.
Хорошая метафора — страховка альпиниста. Верёвка не помогает лезть вверх и даже немного мешает. Но без неё вы полезете только по маршрутам, где точно не сорвётесь, — то есть перестанете развиваться. Плохой набор тестов — верёвка, которая цепляется за каждый выступ и рвётся от ветра: хуже, чем никакой, потому что вы платите за неё и всё равно ей не верите.
Главный сдвиг за тридцать лет — не технический, а организационный. В девяностые тестирование было отдельной фазой: QA-отдел проверял готовый продукт, дефект находили спустя месяцы. С JUnit (Бек и Гамма, 1997) и Extreme Programming тест стал частью работы самого программиста — и превратился из проверки чужой работы в инструмент проектирования собственной. Всё, о чём пойдёт речь дальше, — следствия этого сдвига.
Эта статья — о том, как построить верёвку, которой можно верить.
Четыре свойства хорошего теста
Самая полезная модель качества тестов принадлежит Владимиру Хорикову («Unit Testing Principles, Practices, and Patterns», Manning, 2020). Ценность теста складывается из четырёх свойств, и они перемножаются, а не складываются: ноль по любому из них обнуляет весь тест.
| Свойство | Что означает | Что убивает |
|---|---|---|
| Защита от регрессий | Тест падает, когда ломается поведение | Тест ничего не утверждает или проверяет тривиальщину (геттеры) |
| Устойчивость к рефакторингу | Тест не падает, когда меняется только реализация | Моки на внутренние вызовы, проверка приватных методов, снапшоты всего DOM |
| Быстрая обратная связь | Результат известен за секунды | Реальная БД, сеть, sleep(2), поднятие всего приложения |
| Простота поддержки | Тест легко читать и чинить | 200 строк подготовки, магические числа, общая мутируемая фикстура |
Первые два свойства — это точность классификатора. Тест — детектор поломок, у него бывают ложноотрицательные (пропустил баг — плохая защита от регрессий) и ложноположительные (упал без причины — плохая устойчивость к рефакторингу) срабатывания.
Ложноположительные срабатывания недооценивают катастрофически. Тест, который падает при каждом переименовании поля, обучает команду единственному рефлексу: «упало — поправь тест, не глядя». После этого набор тестов перестаёт быть детектором вообще, потому что настоящее падение обработают тем же рефлексом. Это классическая «слепота к предупреждениям», и она необратима без чистки набора.
Есть и более старая мнемоника — FIRST (Fast, Isolated, Repeatable, Self-validating, Timely), популяризованная в «Clean Code»:
FIRST — про механику, модель Хорикова — про экономику. Пользуйтесь обеими: FIRST отвечает «как писать», Хориков — «стоит ли вообще писать этот тест».
Главное правило: проверяйте наблюдаемое поведение, а не устройство
Если запомнить из статьи один абзац, пусть это будет этот.
Тест должен обращаться к системе так же, как к ней обращается настоящий клиент, и утверждать то же, что заметил бы настоящий клиент. Всё, что клиент не может увидеть, — приватные методы, внутренние вызовы, промежуточные структуры данных, порядок обхода — детали реализации. Тест, который их знает, превращается в бетонную заливку: код застывает, и любая попытка улучшить структуру ломает десятки тестов.
Это прямое продолжение разговора о связанности и связности. Тест — такой же клиент модуля, как и остальной код, и на него распространяются те же правила: он должен зависеть от узкого стабильного контракта, а не от внутренностей.
# ПЛОХО: тест знает, как именно посчитана скидка
def test_discount_calls_tier_resolver():
resolver = Mock()
resolver.resolve.return_value = "gold"
calc = PriceCalculator(tier_resolver=resolver)
calc.total(order)
resolver.resolve.assert_called_once_with(order.customer_id) # деталь реализации
# если завтра tier придёт вместе с заказом — тест упадёт,
# хотя видимое поведение системы не изменится ни на копейку
# ХОРОШО: тест знает, сколько клиент должен заплатить
def test_gold_customer_gets_15_percent_off():
order = an_order(customer=gold_customer(), items=[item(price=1000)])
assert PriceCalculator().total(order) == Money(850)
# реализацию можно переписать целиком — тест остаётся валидным
Что считать «единицей»
Слово «unit» в «unit test» относится не к классу и не к функции, а к единице поведения. Класс — единица организации кода, а не единица смысла. Отсюда две исторические школы:
- Классическая (детройтская, чикагская). Изолируем тесты друг от друга, а не классы друг от друга. Единица под тестом — кластер объектов, который вместе реализует одно поведение. Подменяем только то, что мешает: общие ресурсы, время, сеть.
- Лондонская (мокистская). Изолируем каждый класс: все его сотрудники подменяются моками, тест описывает протокол взаимодействия. Проповедуется в «Growing Object-Oriented Software, Guided by Tests» Фримена и Прайса.
Лондонская школа даёт превосходную локализацию ошибки (упал ровно тот класс, который сломался) и хорошо ведёт проектирование сверху вниз. Платит она устойчивостью к рефакторингу: набор мок-тестов фиксирует граф вызовов, и любое перераспределение ответственности между классами переписывает тесты.
Практический компромисс, к которому пришло большинство команд: классический стиль по умолчанию, лондонский — на границах системы (адаптеры, порты, интеграции). Мартин Фаулер разбирает различие в «Mocks Aren’t Stubs» — статья 2007 года, но не устарела ни на строчку.
Пирамида тестов: что это и чем она не является
Пирамиду нарисовал Майк Кон в «Succeeding with Agile» (2009). Идея простая: чем выше уровень теста, тем он реалистичнее — и тем дороже, медленнее и нестабильнее. Значит, основную массу проверок надо держать внизу, а наверху оставить немного сценариев, подтверждающих, что всё собрано вместе.
Чего пирамида не утверждает: что e2e не нужны (нужны — просто мало и только на критических путях, а не на вариантах бизнес-правил); что соотношение всегда 70/20/10 (это не закон, а следствие типичного распределения риска); что уровень определяется библиотекой (тест в pytest — не автоматически unit). Главный аргумент в пользу формы — экономика обратной связи:
Терминология: почему все спорят
«Юнит», «интеграционный», «компонентный», «системный» — слова без общепринятых определений, и половина споров в командах именно об этом. В «Software Engineering at Google» (глава 11) предложена куда более полезная система координат: два независимых измерения.
- Size — что тесту разрешено трогать. Small: один процесс, без сна, без сети, без диска. Medium: один хост, можно localhost и БД в контейнере. Large — всё остальное.
- Scope — сколько кода проверяется: узкий, средний, широкий.
Эти оси ортогональны: бывает small-тест широкого охвата (весь домен в памяти) и medium-тест узкого охвата (один SQL-репозиторий против настоящего PostgreSQL). Size определяет, в какую стадию CI тест попадёт и насколько он будет флаки; scope — насколько точно он локализует ошибку. Спор «это юнит или интеграционный?» после введения двух осей просто исчезает.
Как выбрать уровень для конкретной проверки:
ветвление, крайние случаи| C[Small, узкий охват] B -->|Склейка модулей,
транзакции, маппинг| D{Нужен реальный
внешний ресурс?} B -->|Пользовательский путь
целиком| E[Large e2e] D -->|Нет: хватит фейка| F[Small, средний охват
сервис + фейк-репозиторий] D -->|Да: SQL, миграции,
сериализация| G[Medium
testcontainers] C --> H{Есть инвариант,
верный для всех входов?} H -->|Да| I[Property-based тест] H -->|Нет| J[Табличный тест
на характерные случаи] E --> K{Можно заменить
контрактным тестом?} K -->|Да| L[Pact / схема ответа
вместо полного прогона] K -->|Нет| M[Оставить, но не больше
десятка критических путей] style C fill:#57a77340,stroke:#57a773 style F fill:#57a77340,stroke:#57a773 style I fill:#57a77340,stroke:#57a773 style G fill:#4a8fd140,stroke:#4a8fd1 style L fill:#4a8fd140,stroke:#4a8fd1 style M fill:#d1704b40,stroke:#d1704b
Рожок мороженого и трофей
Рожок мороженого — перевёрнутая пирамида: масса e2e и ручных проверок, почти нет unit-тестов. Так получается, когда тестирование делегировали отдельному отделу QA: у него нет доступа к внутренностям, единственный доступный интерфейс — пользовательский. Симптомы: прогон идёт часами, падения расследуют вручную, релиз ждёт «зелёного прогона» по два дня, а в код никто не лезет.
Трофей (Кент Си Доддс, 2018) — форма для кода, где мало собственной логики и много склейки: типичный React-фронтенд или тонкий CRUD-сервис. Основной вес — интеграционные тесты, а фундамент — статический анализ: типы, линтер, компилятор. Логика тут здравая: если вся ваша функция — прокинуть поле из HTTP в SQL, unit-тест на неё проверяет ваше умение печатать, а не поведение системы. Подробнее — «Write tests. Not too many. Mostly integration».
Вывод: форма набора — производная от того, где в вашей системе сосредоточен риск. В платёжном движке или планировщике маршрутов риск в алгоритмах — будет пирамида. В интеграционном шлюзе риск в контрактах и маппингах — будет трофей или «песочные часы» (много unit, много контрактных, почти нет e2e).
Анатомия теста
Каноническая структура — Arrange–Act–Assert (в BDD-словаре: Given–When–Then). Три блока, разделённые пустой строкой, в этом порядке, без перемешивания.
def test_order_below_threshold_is_charged_for_delivery():
# Arrange — только то, что важно для сценария
order = an_order(items=[item(price=Money(400))])
# Act — ровно один вызов, ради которого написан тест
invoice = Checkout(delivery=FlatRateDelivery(Money(300))).issue(order)
# Assert — одно наблюдаемое следствие
assert invoice.total == Money(700)
Правила, которые окупаются:
- Один Act на тест. Если в тесте два действия — это два теста или один сценарий с плохим именем. Исключение — тесты состояния, где важна последовательность (тогда явно оформляйте её как один сценарий).
- Имя описывает поведение, а не метод.
test_calculate_total_2не говорит ничего;test_gold_customer_gets_15_percent_off— это документация. Хороший формат:<условие>_<ожидаемый результат>. - Никакой логики в тесте.
if, циклы и арифметика в ассертах означают, что вы написали вторую реализацию — и теперь тестируете её. Ожидаемое значение пишите константой:850, а не1000 * (1 - discount). - DRY в тестах работает иначе. Тест должен читаться сверху вниз без прыжков по файлу. Дублирование трёх строк подготовки лучше, чем хитрая иерархия базовых классов. Об этом компромиссе — в статье про DRY, KISS и YAGNI. Выносить в хелперы стоит намерение («создать золотого клиента»), а не механику.
Тестовые данные: билдеры вместо фикстур
Огромная общая фикстура — самый частый источник нечитаемых тестов: чтобы понять сценарий, надо изучить 80 строк setup и угадать, какие три поля из них влияют на результат. Лечение — паттерн Test Data Builder (или Object Mother): осмысленные значения по умолчанию плюс явное переопределение того, что важно.
# tests/builders.py — вся «неважная» подготовка живёт здесь
def an_order(*, customer=None, items=None, created_at=None) -> Order:
"""Заказ с разумными значениями по умолчанию.
В тесте указывают ТОЛЬКО поля, от которых зависит проверяемое правило,
— поэтому по телу теста сразу видно, что именно на что влияет.
"""
return Order(
customer=customer or a_customer(),
items=items or [item(price=Money(100))],
created_at=created_at or datetime(2026, 1, 1, tzinfo=UTC),
)
# в тесте видно ровно одну значимую деталь
def test_foreign_customer_pays_no_vat():
order = an_order(customer=a_customer(country="DE"))
assert Invoice.for_(order).vat == Money(0)
Тестовые дублёры: что подменять и что проверять
Терминология из «xUnit Test Patterns» Джерарда Месароша: dummy, stub, fake, spy, mock. На практике важна не таксономия, а одно решающее правило, которое сильно упрощает жизнь:
Стабьте входящие зависимости, мокайте исходящие — и утверждайте только про исходящие.
- Входящая зависимость поставляет системе данные для принятия решения: справочник курсов, чтение из репозитория, текущее время. Факт обращения к ней клиенту не виден. Значит, проверять «сколько раз позвали репозиторий» — привязываться к реализации.
- Исходящая зависимость — наблюдаемый внешним миром эффект: письмо ушло, событие опубликовано, деньги списаны. Здесь сам вызов и есть поведение, поэтому проверять его законно и нужно.
from unittest.mock import Mock
class InMemoryOrders:
"""Фейк: рабочая реализация репозитория поверх словаря.
Предпочтительнее стаба-мока: не ломается при добавлении методов,
ловит ошибки вроде «сохранили, но не прочитали».
"""
def __init__(self, seed: list[Order] = ()):
self._items = {o.id: o for o in seed}
def get(self, order_id): return self._items[order_id]
def save(self, order): self._items[order.id] = order
def test_shipping_order_notifies_customer():
orders = InMemoryOrders([an_order(id="A-1")]) # вход: фейк, ассертов по нему нет
notifier = Mock(spec=CustomerNotifier) # выход: мок, по нему ассерт будет
ShipOrder(orders, notifier).execute("A-1")
# проверяем наблюдаемый эффект — клиент получил уведомление об отправке
notifier.notify.assert_called_once_with("A-1", event="shipped")
# и проверяем состояние — заказ действительно перешёл в отправленные
assert orders.get("A-1").status is Status.SHIPPED
Три способа испортить всё моками
- Мок на объект-значение или на собственную структуру данных.
Mock(spec=Money)— всегда ошибка: значения дешёвые, создавайте настоящие. - Мок на чужую библиотеку напрямую. Мок SDK платёжного провайдера кодирует ваши представления о его API. Провайдер изменит поведение — тесты останутся зелёными. Правильно: обернуть SDK тонким адаптером, мокать адаптер, а сам адаптер покрыть интеграционным тестом или контрактом. Стив Фримен назвал это «Don’t mock what you don’t own».
- Мокирование ради нетестируемого дизайна. Если для теста нужно подменить семь сотрудников, тест не «сложный» — он сообщает, что у класса семь причин меняться. Это обратная связь по проектированию, а не повод учить продвинутый синтаксис мок-библиотеки. См. SOLID, раздел про SRP и DIP.
Отдельно: время, случайность и UUID — это тоже зависимости. datetime.now() внутри домена
делает тест недетерминированным, а иногда и сезонно падающим. Прокидывайте clock: Callable[[], datetime]
и генератор идентификаторов явным параметром — это дешевле любой библиотеки заморозки времени
и заодно улучшает дизайн.
TDD: цикл, дисциплина и трезвая оценка
Test-Driven Development — не «сначала тесты, потом код». Это цикл проектирования, у которого тест — побочный продукт.
чтобы тест прошёл Green --> Refactor: тесты зелёные —
есть страховка Refactor --> Red: следующий
маленький шаг Refactor --> [*]: поведение полно,
структура чистая note right of Red КРАСНЫЙ: тест на ОДНО поведение; убеждаемся, что он падает и что сообщение о падении понятное. ЗЕЛЁНЫЙ: разрешено писать плохо — хардкод, дубли, if-ы. РЕФАКТОРИНГ: меняем структуру, НЕ добавляя поведения; тесты не трогаем. end note
Три закона TDD в формулировке Роберта Мартина: не писать продакшн-код, пока нет падающего теста; не писать теста больше, чем нужно для падения; не писать продакшн-кода больше, чем нужно для прохождения. Это тренажёрная дисциплина, а не устав — но месяц строгого соблюдения радикально меняет размер шага, которым вы работаете.
Пример: правило начисления бонусов, шаг за шагом
Шаг 1 — красный. Формулируем простейшее поведение. Тест падает на импорте: bonus_points
не существует. Это нормальный красный — ошибка компиляции тоже считается падением.
def test_no_bonus_for_empty_cart():
assert bonus_points(Money(0), tier="standard") == 0
Шаг 2 — зелёный, самым тупым способом.
def bonus_points(total: Money, tier: str) -> int:
return 0 # да, хардкод — и это правильный шаг
Смысл «фальшивой» реализации не в лени: она доказывает, что тест действительно исполняется и связан с кодом. Половина «зелёных» тестов в легаси-проектах зелены потому, что ничего не проверяют, — этот шаг такое исключает.
Шаги 3–5 — триангуляция. Каждый новый пример ломает предыдущий хардкод и заставляет обобщить реализацию ровно на один шаг:
def test_standard_customer_earns_one_point_per_100_rub():
assert bonus_points(Money(1000), tier="standard") == 10 # → total.rubles // 100
def test_gold_customer_earns_double_points():
assert bonus_points(Money(1000), tier="gold") == 20 # → появляется множитель
def test_points_are_never_negative_for_refund_totals():
assert bonus_points(Money(-500), tier="gold") == 0 # → появляется отсечка
Шаг 6 — рефакторинг (поведение уже покрыто, структуру можно менять смело):
from decimal import Decimal
# Множители вынесены в таблицу: новый уровень лояльности добавляется данными,
# а не новым ветвлением — это OCP на минимальном масштабе.
TIER_MULTIPLIER: dict[str, Decimal] = {
"standard": Decimal(1),
"gold": Decimal(2),
"platinum": Decimal(3),
}
POINTS_PER = Money(100)
def bonus_points(total: Money, tier: str) -> int:
"""Бонусные баллы за покупку. Возвраты (отрицательный итог) баллов не дают."""
if total <= Money(0):
return 0
multiplier = TIER_MULTIPLIER.get(tier, Decimal(1))
return int(total // POINTS_PER * multiplier)
Обратите внимание, что произошло: ни один из пяти тестов не изменился, а реализация переписана трижды. Это и есть устойчивость к рефакторингу в действии.
Outside-in и двойная петля
Отдельная техника — двойная петля (GOOS): внешний цикл на уровне сценария, внутренний — на уровне единиц.
целиком не работает] --> A2[Внутренняя петля] --> A3[Зелёный:
сценарий проходит] end subgraph INNER["Внутренняя петля — unit-тесты"] B1[Красный] --> B2[Зелёный] --> B3[Рефакторинг] --> B1 end A2 -.-> B1 B3 -.-> A3 A3 --> A4[Следующий сценарий]
Внешний тест держит фокус на пользовательской ценности; внутренний — на качестве единиц. Внешний может оставаться красным весь день: он живёт в отдельном прогоне и не блокирует коммиты. Outside-in (от контроллера вниз, недостающие сотрудники — интерфейсы и моки) даёт интерфейсы, продиктованные потребностью клиента, но плодит мок-тесты. Inside-out (от доменного ядра вверх) даёт устойчивые тесты, но рискует построить не то. На практике: outside-in для незнакомой предметной области, inside-out — когда ядро понятно.
Что говорят исследования
Честный ответ: эффект TDD есть, но он скромнее, чем обещают евангелисты.
- Nagappan и др. (Microsoft/IBM, 2008, Empirical Software Engineering): плотность дефектов упала на 40–90%, время разработки выросло на 15–35%.
- Fucci и др. (2016–2017): в контролируемых экспериментах разница между «тест сначала» и «тест сразу после» статистически незначима. Ключевой фактор — размер шага и равномерность чередования кода и тестов, а не порядок. Об ограничениях подхода — серия диалогов Is TDD Dead? Бека, Фаулера и DHH (2014).
Практический вывод: TDD особенно хорош там, где поведение можно сформулировать до кода (алгоритмы, бизнес-правила, парсеры, API) и плох там, где вы ещё исследуете задачу (подбор UI, интеграция с незнакомым API, эксперименты с данными). В исследовательской фазе пишите прототип, а потом выбрасывайте его и восстанавливайте под тестами — это законная стратегия.
BDD: не инструмент, а разговор
Behavior-Driven Development придумал Дэн Норт, обучая людей TDD и заметив, что слово «тест»
уводит разговор не туда. Замена test на should и assert на expect — косметика, а суть в
другом: описание поведения — общий язык аналитика, тестировщика и разработчика.
Это прямое пересечение с идеей единого языка (ubiquitous language) в предметно-ориентированном
проектировании: если аналитик говорит «золотой клиент», а в коде это tier == 2, никакой
общий сценарий вам не поможет — сначала нужно совместить словари.
# features/checkout.feature
Функция: Бесплатная доставка для крупных заказов
Сценарий: заказ выше порога доставляется бесплатно
Допустим корзина клиента на сумму 5000 рублей
Когда клиент оформляет заказ
Тогда стоимость доставки равна 0 рублей
Сценарий: заказ ниже порога оплачивает доставку
Допустим корзина клиента на сумму 400 рублей
Когда клиент оформляет заказ
Тогда стоимость доставки равна 300 рублей
# tests/steps/test_checkout.py — шаги на pytest-bdd
from pytest_bdd import scenarios, given, when, then, parsers
scenarios("../features/checkout.feature")
@given(parsers.parse("корзина клиента на сумму {amount:d} рублей"), target_fixture="cart")
def cart(amount):
return Cart(items=[item(price=Money(amount))])
@when("клиент оформляет заказ", target_fixture="invoice")
def invoice(cart):
return Checkout(delivery=ThresholdDelivery(free_from=Money(3000),
fee=Money(300))).issue(cart)
@then(parsers.parse("стоимость доставки равна {amount:d} рублей"))
def delivery_cost(invoice, amount):
assert invoice.delivery == Money(amount)
Когда Gherkin оправдан. Когда сценарии реально читают и пишут не-разработчики: продакт,
аналитик, регуляторный комплаенс. Тогда .feature-файл — исполняемое требование, и это
огромная ценность.
Когда он вреден. Когда сценарии пишут разработчики для разработчиков. Тогда вы платите двойную цену: слой парсинга строк, хрупкие регулярки, невозможность отрефакторить шаг средствами IDE — и получаете ровно то же, что дал бы обычный тест с хорошим именем. Признак вырождения: сценарии вида «Когда я нажимаю кнопку с id submit-btn» — это уже не язык бизнеса, а разметка, записанная по-русски.
Хороший компромисс: язык Given–When–Then как структура мышления во всех тестах, а Gherkin — только там, где действительно есть внешний читатель.
Property-based тестирование: от примеров к свойствам
Обычный тест проверяет один пример. Property-based тест формулирует свойство, верное для всех
входов, а фреймворк сам ищет контрпример, генерируя сотни значений и затем сжимая (shrinking)
найденное падение до минимального. Идея пришла из QuickCheck для Haskell
(Claessen & Hughes, ICFP 2000);
в Python это Hypothesis, в JS — fast-check, в Go — встроенный
testing/quick и rapid, в Java — jqwik.
from hypothesis import given, strategies as st, assume, settings
money = st.integers(min_value=0, max_value=10_000_000).map(Money)
@given(total=money)
def test_bonus_points_never_negative(total):
"""Свойство-инвариант: баллы неотрицательны при любой сумме."""
assert bonus_points(total, tier="standard") >= 0
@given(a=money, b=money)
def test_bonus_is_monotonic(a, b):
"""Метаморфическое отношение: больше потратил — не меньше баллов."""
assume(a <= b)
assert bonus_points(a, "gold") <= bonus_points(b, "gold")
@given(order=st.builds(an_order))
@settings(max_examples=500)
def test_serialization_round_trip(order):
"""Классика жанра: decode(encode(x)) == x — ловит потерю точности и полей."""
assert Order.from_json(order.to_json()) == order
Сжатие (shrinking) — вторая половина ценности: получив падение на сумме 7_431_902,
Hypothesis автоматически ищет минимальный контрпример и покажет вам Money(1) или Money(0),
то есть сразу укажет на границу, а не на случайное число.
Типовые семейства свойств, которые стоит держать в голове:
| Семейство | Формулировка | Пример |
|---|---|---|
| Round-trip | decode(encode(x)) == x |
JSON, protobuf, URL-кодирование |
| Инвариант | результат всегда в допустимом множестве | баланс не отрицателен, список отсортирован |
| Метаморфическое отношение | связь между результатами на разных входах | монотонность, коммутативность скидок |
| Тест-оракул | сравнение с эталонной (медленной) реализацией | новый быстрый поиск против линейного |
| Идемпотентность | f(f(x)) == f(x) |
нормализация, применение миграции |
Стоимость: генерация в сотни раз медленнее одного примера (держите такие тесты в отдельном
прогоне, если их много), и нужна дисциплина с сидом — Hypothesis сама сохраняет упавшие примеры
в .hypothesis/examples, эту директорию полезно кэшировать в CI. Отличное практическое введение —
Hillel Wayne, «Property-Based Testing in Practice».
Родственная техника — табличные (parametrized) тесты: тот же принцип «много входов, одно утверждение», но входы перечислены руками. В Go это идиома по умолчанию:
func TestBonusPoints(t *testing.T) {
tests := []struct {
name string
total int
tier string
want int
}{
{"пустая корзина", 0, "standard", 0},
{"золотой клиент", 1000, "gold", 20},
{"возврат не даёт баллов", -500, "gold", 0},
{"неизвестный уровень — базовый множитель", 1000, "unknown", 10},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { // подтест: имя видно в отчёте
if got := BonusPoints(tt.total, tt.tier); got != tt.want {
t.Errorf("BonusPoints(%d, %q) = %d, хотим %d", tt.total, tt.tier, got, tt.want)
}
})
}
}
Подробнее об идиоматике Go-тестов — в статье Тестирование в Go.
Интеграционные и контрактные тесты
Правило для интеграционного уровня: проверяйте то, что нельзя проверить в памяти, — SQL и миграции, сериализацию, транзакционные границы, поведение при потере соединения. Всё остальное логичнее оставить внизу.
Ключевой инструмент последнего десятилетия — Testcontainers: настоящая БД или брокер поднимается в Docker на время теста. Это дороже фейка, но убирает целый класс ошибок «в H2 работало, в PostgreSQL нет».
import pytest
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def pg_dsn():
# Контейнер поднимается один раз на сессию: старт ~2 c, платить им за каждый тест нельзя.
with PostgresContainer("postgres:16-alpine") as pg:
dsn = pg.get_connection_url()
run_migrations(dsn) # миграции проверяем заодно — это тоже код
yield dsn
@pytest.fixture
def repo(pg_dsn):
# Изоляция между тестами — транзакция с откатом.
# Быстрее и надёжнее, чем TRUNCATE всех таблиц.
with connect(pg_dsn) as conn, conn.transaction(rollback=True):
yield SqlOrderRepository(conn)
def test_saved_order_is_read_back_with_all_items(repo):
order = an_order(items=[item(price=Money(100)), item(price=Money(250))])
repo.save(order)
assert repo.get(order.id) == order # ловит забытый маппинг поля и потерю точности Money
Контрактное тестирование вместо сквозных прогонов
В распределённой системе соблазн велик: поднять все сервисы и прогнать сценарии. Это самый дорогой и самый нестабильный способ проверить, что потребитель и поставщик договорились о формате. Дешевле — consumer-driven contracts: потребитель записывает свои ожидания, поставщик проверяет их у себя, оба тестируются независимо.
(сервис заказов) participant MS as Мок-сервер Pact participant BR as Брокер контрактов participant PT as Тесты поставщика
(сервис клиентов) participant PR as Реальный поставщик CT->>MS: описываю ожидание: GET /customers/42 → {id, tier} MS-->>CT: отвечаю по контракту Note over CT,MS: тест потребителя зелёный,
поставщик даже не запущен CT->>BR: публикую пакт (версия + тег ветки) PT->>BR: забираю все пакты моих потребителей loop по каждому взаимодействию PT->>PR: воспроизвожу запрос на реальном сервисе PR-->>PT: настоящий ответ PT->>PT: сверяю со схемой ожидания end PT->>BR: публикую результат верификации BR-->>PR: can-i-deploy? → да, если все потребители зелёные Note over BR,PR: развёртывание блокируется контрактом,
а не общим стендом
Так проверяется совместимость схем и семантики, а не работоспособность всей системы. Полный e2e при этом не исчезает — но сокращается до нескольких дымовых сценариев. Материалы: документация Pact и «Contract Testing» у Фаулера.
Флаки-тесты: главный убийца доверия
Флаки-тест — тест, который на одном и том же коде иногда зелёный, иногда красный. Один такой тест в наборе из тысячи безобиден. Двадцать — и команда начинает перезапускать сборку не глядя, после чего набор тестов теряет всякий смысл. В Google доля флаки-падений исторически измерялась примерно 1,5% всех прогонов, и с ними борются как с инцидентами.
Основные причины и лечение:
| Причина | Симптом | Лечение |
|---|---|---|
| Время и часовые пояса | Падает ночью, 29 февраля, при переходе на летнее время | Инъекция часов, фиксированные даты, всё в UTC |
| Порядок выполнения | Падает при запуске всего набора, проходит в одиночку | Свежее состояние на тест, случайный порядок (pytest -p randomly) |
| Конкурентность | Падает раз в сто прогонов | Убрать sleep, ждать условие с таймаутом, детерминированный планировщик |
| Общий ресурс | Падает при параллельном прогоне | Уникальные схемы/префиксы на воркер, транзакции с откатом |
| Сеть и внешние API | Падает по вторникам | Не ходить в интернет из тестов; VCR/записанные ответы, контракты |
| Асинхронный UI | «Element not found» | Ждать по условию, а не по таймеру; стабильные data-testid |
Организационная часть важнее технической: заведите карантин. Флаки-тест помечается, выносится из блокирующего прогона, на него создаётся задача с дедлайном, и по истечении срока он удаляется. Тест без владельца, который «иногда падает», — мусор, и висеть он должен не в CI, а в бэклоге. Каноническая статья — «Eradicating Non-Determinism in Tests».
Покрытие, закон Гудхарта и мутационное тестирование
Покрытие кода измеряет ровно одно: какие строки исполнялись во время прогона. Оно не измеряет, проверил ли тест хоть что-нибудь. Тест без единого ассерта даёт 100% покрытия.
Как только покрытие становится KPI, включается закон Гудхарта — «мера, ставшая целью, перестаёт
быть хорошей мерой». Команды дописывают тесты на геттеры и __str__, а сложные ветвления
остаются непроверенными, потому что их дорого покрывать. Разумное использование метрики:
- Смотреть на покрытие как на детектор дыр, а не как на цель: непокрытый участок — повод спросить «а это вообще нужно?».
- Требовать покрытия на диффе (новый код), а не общего процента по проекту.
- Различать line coverage и branch coverage: первое почти бесполезно, второе полезно.
Честная альтернатива — мутационное тестирование. Инструмент вносит в код мелкие искажения
(> → >=, + → -, удаление строки, return True вместо вычисления) и запускает тесты.
Если после мутации все тесты зелёные — мутант «выжил», значит поведение никем не проверяется.
# Python — mutmut
pip install mutmut
mutmut run --paths-to-mutate src/pricing/
mutmut results # список выживших мутантов = дыры в ассертах
# JS/TS — Stryker
npx stryker run # выдаёт mutation score вместо coverage
Пример из практики: у модуля 96% покрытия и mutation score 41%. Это означает, что код
исполняется тестами, но почти не проверяется — типичная картина при обилии «тестов-дымоходов»,
которые вызывают функцию и утверждают assert result is not None.
Мутационное тестирование дорого по времени (полный прогон набора на каждую мутацию), поэтому в проде его запускают ночью или только на изменённых файлах. Зато для критичных модулей — расчёт денег, авторизация, скидки — это единственная метрика, которой стоит верить.
Типичные ошибки
- Тесты на приватные методы. Если приватный метод хочется протестировать отдельно — внутри класса прячется отдельная сущность. Выделите её и тестируйте через публичный контракт.
- Один тест на всё.
test_checkout()на 120 строк с пятнадцатью ассертами. При падении первого ассерта остальные не выполняются, и вы чините по одному дефекту за прогон. - Ассерты без сообщения о смысле.
assert result == expected— при падении вы видите «True != False». Сравнивайте объекты целиком, а не булевы флаги:assert invoice == expected_invoiceдаёт разницу по полям. - Логика внутри теста. Если в тесте есть
forилиif, вы тестируете свой цикл. - Общая мутируемая фикстура на модуль. Один тест испортил объект — падает соседний. Симптом: тесты проходят по одному и падают вместе.
- Тесты, повторяющие структуру кода один-в-один.
UserServiceTest,UserRepositoryTest,UserMapperTest— при слиянии двух классов переписывается всё. Организуйте тесты по поведению, а не по файлам. - Проверка вызовов вместо результата. Обсуждали выше:
assert_called_withна входящую зависимость — почти всегда ошибка. - Игнорируемые тесты и тестирование фреймворка.
@pytest.mark.skipбез даты и владельца живёт вечно и создаёт ложное чувство покрытия; проверка того, что ORM сохраняет поле, — тестирование чужого кода. - Отсутствие проверки красного. Тест, который никогда не падал, не доказал, что умеет падать. Всегда ломайте код на секунду, чтобы убедиться в связи теста с реализацией.
Как это выглядит в проде
Стадии CI. Набор делится по стоимости, и разработчик не ждёт медленное:
формат, линтер
~5 c"] B --> C["Стадия 1: small
unit + property
< 2 мин"] C -->|красный| X[Блок мержа,
уведомление автору] C --> D["Стадия 2: medium
testcontainers, контракты
< 10 мин"] D --> E["Стадия 3: large
10-20 e2e на критичных путях
< 25 мин"] E --> F[Мерж в main] F --> G["Ночью: мутационное
тестирование, нагрузка,
полный e2e-регресс"] F --> H["Канареечный релиз
5% трафика + метрики"] H -->|SLO в норме| I[Полный раскат] H -->|деградация| J[Автооткат] style C fill:#57a77340,stroke:#57a773 style D fill:#4a8fd140,stroke:#4a8fd1 style E fill:#d1704b40,stroke:#d1704b style J fill:#d1704b40,stroke:#d1704b
Тест-импакт-анализ. В больших монорепозиториях прогонять весь набор на каждый коммит невозможно. Строится граф зависимостей, и запускаются только тесты, затронутые изменением, — так работают Bazel, Nx, Gradle с build cache. Экономия на порядок, но требует, чтобы тесты были герметичными: не зависели от внешнего состояния, иначе кэш соврёт.
Тестирование в проде (shift-right). Пирамида не покрывает всё: часть поведения проявляется только на реальном трафике, реальных данных и реальной нагрузке. Отсюда практики: канареечные релизы, feature-флаги с постепенным раскатом, синтетический мониторинг критичных путей, теневой трафик (копия запросов на новую версию без влияния на ответ), chaos-эксперименты. Это не замена автотестам, а признание того, что 100% уверенности до деплоя не бывает — и тогда лучше вкладываться в скорость обнаружения и отката. Тема близко связана с обработкой ошибок и отказоустойчивостью.
Тесты как часть Definition of Done. Требование «покрыто тестами» бессмысленно без критериев. Работающая формулировка: новое поведение покрыто тестом на подходящем уровне; исправление бага начинается с теста, воспроизводящего баг; ни один тест не отключён без задачи; прогон на ветке зелёный без перезапусков. Об этом — в статье про код-ревью и командные стандарты.
Легаси без тестов. Каноническое руководство — Майкл Фэзерс, «Working Effectively with Legacy
Code»: находим шов (seam) — место, где поведение можно подменить, не меняя исходник, — накрываем
участок характеризационными тестами (фиксируем текущее поведение как эталон, даже если оно
неверное), и только потом рефакторим. Прикладной инструмент — approval-тесты:
approvals.verify(render_invoice(order)) при первом прогоне сохраняет вывод в .approved.txt,
дальше сравнивает с ним и падает на любом расхождении.
Мини-итог
- Тесты нужны не для поиска багов, а для того, чтобы менять код без страха. Всё остальное — следствие этой цели.
- Ценность теста = защита от регрессий × устойчивость к рефакторингу × скорость × простота поддержки. Ноль по любому множителю обнуляет тест.
- Проверяйте наблюдаемое поведение, а не устройство. Тест — такой же клиент модуля, как остальной код.
- Пирамида — не догма, а следствие распределения риска. Считайте не «уровни», а две оси: size (что трогает) и scope (сколько покрывает).
- Стабьте входящие зависимости, мокайте исходящие, ассертьте только исходящие. Фейк почти всегда лучше мока.
- TDD — техника проектирования маленькими шагами; эффект подтверждён, но умеренный, и применима она не везде. BDD — про общий язык, а не про Cucumber.
- Property-based тесты ловят то, о чём вы не подумали; мутационное тестирование измеряет качество ассертов честнее, чем покрытие.
- Флаки-тест хуже отсутствующего. Карантин с дедлайном — обязательная практика.
Источники
- Kent Beck. Test-Driven Development: By Example, 2002. Michael Feathers. Working Effectively with Legacy Code, 2004.
- Steve Freeman, Nat Pryce. Growing Object-Oriented Software, Guided by Tests, 2009.
- Vladimir Khorikov. Unit Testing: Principles, Practices, and Patterns, Manning, 2020.
- Gerard Meszaros. xUnit Test Patterns — каталог паттернов и запахов тестов.
- Titus Winters et al. Software Engineering at Google, главы 11–14.
- Martin Fowler. Mocks Aren’t Stubs, Test Pyramid, Eradicating Non-Determinism in Tests, Is TDD Dead?
- Dan North. Introducing BDD, 2006. Kent C. Dodds. Write tests. Not too many. Mostly integration, 2018.
- Claessen, Hughes. QuickCheck, ICFP 2000. Google Testing Blog. Flaky Tests at Google
- Документация: pytest, Hypothesis, Testcontainers, Pact, mutmut, Stryker
Что дальше
Мы разобрались, как убедиться, что код делает то, что должен. Следующий шаг — как этот код должен быть устроен, чтобы его можно было надёжно конфигурировать, разворачивать и масштабировать в облаке: Twelve-Factor App и cloud-native принципы.