TDD и BDD на практике: как это выглядит в реальной работе
Про TDD и BDD написано больше, чем про любую другую практику тестирования, и почти всё написанное бесполезно по одной причине: обсуждают ритуал, а не механизм. «Сначала пиши тест» — это ритуал. Механизм в другом: пока тест не написан, у вас нет операционального определения того, что значит «готово», и вы физически не способны отличить работающий код от кода, который выглядит работающим. TDD — это способ получить это определение до того, как вы влюбитесь в свою реализацию.
BDD страдает от той же подмены, только сильнее. Его свели к синтаксису Given/When/Then и инструменту Cucumber,
хотя Дэн Норт придумывал его как способ переучить программистов задавать вопросы бизнесу. Отсюда самая частая
судьба BDD в компаниях: команда покупает инструмент, пишет тысячу .feature-файлов, которые никто вне команды
не читает, платит 25% лишнего кода за красивые HTML-отчёты и через год тихо переезжает обратно на обычные тесты.
Эта статья — про то, как обе практики выглядят, когда их применяют по делу: с настоящим кодом, настоящими цифрами из исследований и честным разбором мест, где они не работают.
Сразу о расхождении взглядов. Для разработчика TDD — это в первую очередь инструмент проектирования: тест написан до кода, значит, код обязан быть вызываемым в изоляции, значит, зависимости придётся вынести наружу. Тесты здесь — побочный продукт, а главный продукт — дизайн. Для тестировщика TDD — это чужой процесс, в который он обычно не вмешивается, но который резко меняет его работу: у разработчика уже покрыта вся логика ветвлений, и добирать надо не «а что если минус один», а интеграции, данные, окружения и то, что вообще не приходило в голову автору. С BDD зеркально: там центр тяжести смещён к тестировщику и аналитику, а разработчик чаще всего воспринимает Gherkin как лишний слой между собой и ассертом. Обе позиции обоснованы, и я буду отмечать, где они расходятся.
1. Механика цикла: что именно происходит в red-green-refactor
Канонический цикл Кента Бека из «Test-Driven Development: By Example» (книга) состоит из трёх состояний и очень жёстких правил перехода между ними.
что должно работать TestList --> Red: взяли ОДИН пункт,
написали падающий тест Red --> Red: тест падает не по той причине?
чиним тест, не код Red --> Green: минимальный код,
чтобы тест прошёл Green --> Refactor: все тесты зелёные —
только теперь можно чистить Refactor --> Green: рефакторинг сломал тест?
откат, шаг был слишком большим Refactor --> TestList: дописали новые пункты,
которые вскрылись по ходу TestList --> [*]: список пуст
Три правила, которые нарушают чаще всего:
Красный должен быть красным по правильной причине. Тест, падающий с ImportError или AttributeError,
не является красной фазой — он ничего не проверил. Красная фаза считается пройденной, только когда тест падает
на ассерте с внятным сообщением: ожидали 700, получили 1000. Это одновременно проверка самого теста: если
вы никогда не видели его падающим, вы не знаете, умеет ли он падать. Тест, который не был красным ни разу,
статистически часто оказывается тестом, который не проверяет ничего.
Зелёная фаза — не место для красоты. Разрешено всё: захардкоженная константа, if-заглушка, копипаст.
Цель фазы — как можно быстрее вернуть систему в состояние «всё зелёное», потому что только из этого состояния
безопасен рефакторинг. Бек называл это «faking it» и «triangulation»: возвращаем константу, потом добавляем
второй тест, который константу ломает, и только тогда пишем настоящий алгоритм.
Рефакторинг делается на зелёном и без новых тестов. Если во время рефакторинга захотелось добавить поведение — стоп, это пункт в список тестов, не в текущий шаг. Смешение «чиню структуру» и «добавляю функцию» — главная причина, по которой у людей «TDD не взлетел»: они делают шаг на два часа, ловят десять красных тестов и не понимают, какое из десяти изменений виновато.
Живой пример: считаем возврат средств
Возьмём задачу из предыдущего SVG — расчёт суммы возврата за отменённый заказ. Правила: возврат недоступен после 30 дней (граница включена); если заказ уже отгружен, удерживается стоимость доставки 300 ₽; сумма возврата не может быть отрицательной.
Цикл 1. Список тестов пуст, начинаем с самого простого случая, который вообще имеет смысл.
# tests/test_refund.py — шаг 1: красный
from decimal import Decimal
from refund import calculate_refund, Order
def test_отмена_до_отгрузки_возвращает_всю_сумму():
order = Order(total=Decimal("1000"), shipped=False, days_since_purchase=1)
assert calculate_refund(order) == Decimal("1000")
Красный: модуля refund нет. Это ещё не настоящий красный — правим до состояния «падает на ассерте»,
создав пустые заготовки, а потом делаем минимальное зелёное:
# refund.py — шаг 1: зелёный. Да, это константа. Так и надо.
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class Order:
total: Decimal
shipped: bool
days_since_purchase: int
def calculate_refund(order: Order) -> Decimal:
return Decimal("1000")
Рефакторить нечего. Идём дальше.
Цикл 2 — триангуляция. Добавляем тест, который ломает константу.
def test_отмена_после_отгрузки_удерживает_доставку():
order = Order(total=Decimal("1000"), shipped=True, days_since_purchase=1)
assert calculate_refund(order) == Decimal("700")
SHIPPING_FEE = Decimal("300")
def calculate_refund(order: Order) -> Decimal:
return order.total - SHIPPING_FEE if order.shipped else order.total
Цикл 3 — граница. Здесь смыкаются TDD и тест-дизайн: пункты в списке тестов берутся не из вдохновения, а из анализа границ и классов эквивалентности. Граница «30 дней включительно» даёт сразу три пункта: 30, 31 и, для симметрии, 0.
import pytest
@pytest.mark.parametrize("days,expected", [
(0, Decimal("1000")), # покупка сегодня
(30, Decimal("1000")), # последний допустимый день — граница включена
(31, Decimal("0")), # уже поздно
])
def test_граница_срока_возврата(days, expected):
order = Order(total=Decimal("1000"), shipped=False, days_since_purchase=days)
assert calculate_refund(order) == expected
REFUND_WINDOW_DAYS = 30
def calculate_refund(order: Order) -> Decimal:
if order.days_since_purchase > REFUND_WINDOW_DAYS:
return Decimal("0")
return order.total - SHIPPING_FEE if order.shipped else order.total
Цикл 4 — тот самый случай, который никто не вспоминает без теста. Заказ ровно на 300 ₽, уже отгружен. По формуле выйдет ноль, а по коду — тоже ноль. А заказ на 200 ₽? Минус сто. Именно на этом шаге TDD показывает свою настоящую ценность: вы натыкаетесь на дыру в требованиях в момент написания теста, а не через полгода в отчёте бухгалтерии.
def test_возврат_не_может_быть_отрицательным():
order = Order(total=Decimal("200"), shipped=True, days_since_purchase=1)
assert calculate_refund(order) == Decimal("0")
def calculate_refund(order: Order) -> Decimal:
"""Сумма к возврату. Никогда не отрицательна."""
if order.days_since_purchase > REFUND_WINDOW_DAYS:
return Decimal("0")
withheld = SHIPPING_FEE if order.shipped else Decimal("0")
return max(Decimal("0"), order.total - withheld)
Четыре цикла, семь тестовых случаев, найденная дыра в требованиях, ноль отладки. Обратите внимание, чего
здесь не было: ни одного мока, ни одного обращения к базе, ни одного sleep. Так выглядит TDD в своей
естественной среде — на чистой логике. Дальше начнутся сложности.
2. Что говорит эмпирика: цифры вместо веры
Здесь принято ссылаться на «исследования доказали», не называя их. Назовём.
Nagappan, Maximilien, Bhat, Williams (2008), четыре промышленные команды в Microsoft и IBM (Empirical Software Engineering): плотность дефектов у команд с TDD оказалась ниже сопоставимых проектов на 40–90%, при этом время разработки выросло на 15–35%. Это самая цитируемая цифра в пользу TDD, и она честная — но обратите внимание на цену.
Fucci и соавторы (2016–2017), серия контролируемых экспериментов с многоцентровым слепым анализом (статья, «A Dissection of the Test-Driven Development Process» в IEEE TSE): разницы между «тест до» и «тест после» по качеству и продуктивности не обнаружено. Зато обнаружено, что предсказывает результат: гранулярность и равномерность цикла. Люди, работающие короткими однородными итерациями «маленький кусок теста — маленький кусок кода — прогон», выигрывают независимо от того, в каком порядке они писали тест и код.
Это самый практически важный вывод обо всём TDD, и он контринтуитивен: дисциплина маленького шага — действующее вещество, а «сначала тест» — упаковка, которая помогает эту дисциплину удерживать. Человек, который пишет тест сразу после каждых пяти строк кода и гоняет их непрерывно, получает почти всё то же самое. Человек, который «сделал по TDD» двухчасовой кусок с двадцатью ассертами в одном тесте, не получает ничего.
Отсюда практический критерий для ревью: не спрашивайте «писал ли ты тест первым» — это непроверяемо и провоцирует вранье. Спрашивайте «сколько времени прошло между двумя зелёными прогонами». Если больше десяти-пятнадцати минут, шаг был слишком большим.
Третье, о чём стоит знать: знаменитая публичная ссора «Is TDD Dead?» (2014). DHH написал «TDD is dead. Long live testing», обвинив тест-первый подход в порождении «test-induced design damage» — искажении архитектуры ради тестируемости. Затем он, Кент Бек и Мартин Фаулер провели серию разговоров (расшифровки у Фаулера). Итог разговоров куда полезнее самого скандала: все трое сошлись на том, что вред возникает, когда тесты пишутся на внутреннюю структуру, а изоляция достигается тотальным мокингом. Это ровно та же болезнь, что разбиралась в статье про юнит-тесты, и она не лечится отказом от TDD — она лечится выбором правильной единицы тестирования.
3. Две школы: детройтская и лондонская
Разделение на школы — не академическая тонкость, а причина, по которой два разработчика, оба «делающие TDD», пишут несовместимый код и ругаются на ревью.
Детройтская школа (она же классическая, чикагская, inside-out). Идёт от Бека и оригинального сообщества Extreme Programming. Начинаем с домена, движемся изнутри наружу. Дублёры применяются только на границе с внешним миром. Пример выше — чистая детройтская школа.
Лондонская школа (мокистская, outside-in). Идёт от Стива Фримена и Ната Прайса, «Growing Object-Oriented Software, Guided by Tests» (книга). Начинаем с внешнего интерфейса, движемся внутрь. Соседи, которых ещё нет, подменяются моками — и именно эти моки проектируют интерфейсы будущих соседей. Мок здесь не средство изоляции, а инструмент дизайна: «я хочу, чтобы вот этот объект умел вот такой вызов».
// Лондонская школа: мок задаёт интерфейс ещё не написанной зависимости.
// TypeScript + vitest.
import { describe, it, expect, vi } from 'vitest';
import { RefundService } from '../src/refund-service';
describe('RefundService', () => {
it('списывает возврат через платёжный шлюз и фиксирует событие', async () => {
// Этих двух классов ещё НЕ существует. Мок — это черновик их контракта.
const gateway = { refund: vi.fn().mockResolvedValue({ id: 'rf_1' }) };
const events = { publish: vi.fn() };
const service = new RefundService(gateway, events);
await service.refundOrder({ id: 'o-42', total: 1000, shipped: true, daysSincePurchase: 1 });
// Проверяем исходящее взаимодействие с ВНЕШНЕЙ системой — это законно.
expect(gateway.refund).toHaveBeenCalledWith({ orderId: 'o-42', amount: 700 });
expect(events.publish).toHaveBeenCalledWith(
expect.objectContaining({ type: 'RefundIssued', orderId: 'o-42' }),
);
});
});
Разница в последствиях:
| Детройтская | Лондонская | |
|---|---|---|
| Направление | изнутри наружу | снаружи внутрь |
| Роль дублёров | только внешние системы | все соседи SUT |
| Что фиксирует тест | состояние/результат | взаимодействия |
| Локализация падения | хуже: падает много тестов сразу | лучше: падает ровно виновник |
| Переживаемость рефакторинга | высокая | низкая: перестановка вызовов ломает тесты |
| Где уместна | домен, вычисления, правила | оркестрация, адаптеры, слои-координаторы |
Практический вывод, который я считаю единственно рабочим: школа выбирается по слою, а не по вкусу. В доменном ядре — детройтская, потому что там ценна свобода рефакторинга и тесты обязаны переживать перестановку классов. В слоях-координаторах, где у объекта нет собственного состояния и вся его работа — позвать трёх соседей в правильном порядке, проверять нечего, кроме взаимодействий, и лондонская школа там естественна. Ошибка — тащить моки в домен: тогда получается тот самый «test-induced design damage», на который жаловался DHH.
Ian Cooper в докладе «TDD, Where Did It All Go Wrong» (2013) формулирует это ещё жёстче: единица тестирования — не класс, а порт/модуль, тесты пишутся через публичный API и переживают полную перекройку внутренностей. Если после безобидного рефакторинга у вас упало сорок тестов — тестировали не то.
4. Где TDD не работает и что делать вместо
Честный список, потому что «TDD везде» — это позиция консультанта, а не человека, который пишет код.
Исследовательский код и алгоритмика с неизвестным решением. Вы не можете написать тест на результат, которого не знаете. Классический пример — численные методы, ML-эксперименты, парсинг грязного формата. Рабочая замена: spike & stabilize. Пишете грязный прототип без тестов, выясняете форму решения, выбрасываете прототип и переписываете по TDD, уже зная ответы. Ключевое слово — выбрасываете; прототип, доехавший до продакшна, и есть источник легаси.
UI и вёрстка. Никакой тест не скажет, что кнопка съехала на 3 пикселя и стала уродливой. Здесь работает не TDD, а визуальная регрессия и человеческий глаз — об этом в статье про E2E и UI.
Легаси без швов. Написать тест первым невозможно, если класс в конструкторе лезет в базу и статический синглтон. Здесь применяется процедура Майкла Физерса из «Working Effectively with Legacy Code»: сначала характеризующие тесты (характеризуют то, что код делает сейчас, включая баги — вы фиксируете поведение, а не правильность), потом внедрение шва, потом уже TDD для новых изменений.
Конфигурация и инфраструктура. Тест «в yaml лежит именно то, что лежит в yaml» — тавтология. Проверять надо результат применения конфигурации в интеграционном тесте, а не сам файл.
Код с высокой ценой изменения тестов и низкой ценой ошибки. Одноразовые скрипты миграции, внутренние админки на десять пользователей. Здесь стоимость тестирования превышает выигрыш, и это нормальное инженерное решение, а не лень.
5. BDD: что это на самом деле
Дэн Норт придумал BDD в 2006 году как терапию для обучения TDD
(«Introducing BDD»). Наблюдение было такое: люди спотыкались не о
технику, а о слово «тест». Услышав «тест», человек начинает думать о проверке уже написанного кода. Норт
заменил testX() на shouldX() — и вопрос «что должна делать система?» неожиданно оказался легче
для новичков, чем «как это проверить?».
Дальше BDD вырос за пределы кода и стал практикой совместного выяснения требований на примерах. Гойко Аджич описал ту же практику под названием Specification by Example (книга). Формула, которую стоит запомнить дословно:
BDD — это структурированный разговор о примерах, который иногда заканчивается автоматизацией. Автоматизация — необязательная третья фаза, а не суть.
Три фазы (терминология из «The BDD Books» Себа Роуза и Гашпара Надя):
Открытие] --> B[Formulation
Формулирование] B --> C[Automation
Автоматизация] C -.живая документация.-> A A1["Три амиго за доской.
Example Mapping, 25 минут.
Находим непонимание,
пока это дёшево"] -.- A B1["Примеры переписываем
на языке предметной области.
Gherkin — один из вариантов,
не обязательный"] -.- B C1["Шаги цепляем к системе.
Чаще всего — к API,
а не к UI"] -.- C style A stroke:#4caf82,stroke-width:2px style B stroke:#4a8fd4,stroke-width:2px style C stroke:#e0a252,stroke-width:2px
Главная ценность — в первой фазе. Именно там находят требования, которые никто не продумал, и именно там экономия. Если вы делаете только третью — вы делаете не BDD, а автотесты с необычным синтаксисом.
Три амиго и Example Mapping
«Три амиго» — это встреча представителей трёх точек зрения на историю перед тем, как брать её в работу: бизнес (что нам нужно), разработка (как это возможно сделать), тестирование (что здесь может сломаться). Роли, а не должности: в маленькой команде «амиго» может быть двое.
Формат встречи, который реально работает, — Example Mapping Мэтта Уинна (cucumber.io). Таймбокс 25 минут, четыре цвета стикеров, никаких обсуждений реализации.
Метод даёт три измеримых сигнала прямо на доске:
- Много синих карточек (правил) — история слишком большая, режьте её по правилам.
- Много красных (вопросов без ответа) — историю рано брать в спринт, она не готова.
- Правило без единого зелёного примера — участники думают, что поняли правило, но не могут привести случай. Почти всегда это означает, что поняли его по-разному.
Отдельно отмечу расхождение взглядов. Тестировщик на такой встрече — самый ценный участник, потому что он единственный профессионально натренирован задавать вопрос «а что если нет». Именно от него приходят красные карточки про часовые пояса, двойную оплату и заказ ровно на 300 ₽. Разработчик обычно приходит на встречу нехотя и уходит с ощущением, что сэкономил себе полдня — потому что не написал код по требованию, которое поменялось бы на следующий день. Продавать Example Mapping разработчикам надо именно так, а не через «это правильная практика».
6. Gherkin: как писать сценарии, которые не превратятся в мусор
Gherkin — язык, а не фреймворк. Синтаксис минимален: Feature, Scenario, Given/When/Then/And/But,
Background, Scenario Outline с Examples, теги. Формальная
спецификация у Cucumber.
Хороший сценарий:
# language: ru
Функция: Возврат средств за отменённый заказ
Как покупатель
Я хочу вернуть деньги за отменённый заказ
Чтобы не терять средства при отказе от покупки
Предыстория:
Допустим стоимость доставки составляет 300 рублей
И срок возврата составляет 30 дней
Структура сценария: Сумма возврата зависит от статуса отгрузки и срока
Допустим заказ на <сумма> рублей со статусом "<статус>"
И с момента покупки прошло <дней> дней
Когда покупатель отменяет заказ
Тогда к возврату полагается <возврат> рублей
Примеры:
| сумма | статус | дней | возврат |
| 1000 | не отгружен | 1 | 1000 |
| 1000 | отгружен | 1 | 700 |
| 1000 | не отгружен | 30 | 1000 |
| 1000 | не отгружен | 31 | 0 |
| 200 | отгружен | 1 | 0 |
Плохой сценарий — тот же смысл, но нечитаемый и хрупкий:
Сценарий: Возврат
Допустим я открыл "https://shop.example.com/login"
И я ввёл "user@test.ru" в поле с id "email"
И я ввёл "Passw0rd!" в поле с id "password"
И я нажал на кнопку с css-селектором "button.btn-primary"
И я подождал 3 секунды
И я перешёл на "/orders"
И я нажал на третью строку таблицы
Когда я нажал "Отменить"
Тогда я вижу текст "700"
Что здесь сломано:
- Императивность вместо декларативности. Сценарий описывает клики, а не правило. Смена вёрстки ломает бизнес-спецификацию — а спецификация не должна знать про CSS-селекторы.
- Бизнес такое не читает. Значит, весь смысл слоя Gherkin потерян: вы платите цену, не получая выгоды.
- Магические значения без объяснения. Почему 700? Правило спрятано в голове автора.
Тогда я вижу текст "700"— проверка на подстроку по всей странице. Пройдёт, если 700 встретится в номере заказа.подождал 3 секунды— гарантированный флак, см. E2E и UI.- Логин в каждом сценарии. Состояние надо готовить через API или прямо в БД, а не кликами.
Правило, которым легко пользоваться на ревью: если в шаге встречается слово «нажал», «поле», «кнопка», «страница» — шаг написан не на том уровне. Исключение ровно одно: сценарии, где сам UI и есть предмет требования.
Ещё несколько практических правил:
- Один
Когдана сценарий. ДваКогдаозначают, что вы описываете процедуру, а не поведение. Разрежьте. Тогдане содержит действий. Никаких «Тогда я перехожу в админку и вижу». Это второй сценарий.Структура сценария— для одного правила с разными данными, а не для перечисления всей матрицы. Таблица на сорок строк — это данные-тест, ей место в параметризованном юнит-тесте, а не в спецификации.- Теги — механизм управления прогонами:
@smoke,@slow,@wip. В CI собираются разные наборы, см. тесты в CI. - Сценариев на фичу — единицы. Полная комбинаторика идёт на уровень ниже. Gherkin описывает правила, а не покрывает классы эквивалентности.
7. Автоматизация: код, который стоит за сценарием
Python: pytest-bdd
pytest-bdd удобен тем, что не создаёт отдельный ранер: сценарии становятся обычными pytest-тестами и живут в одном прогоне с юнит-тестами, фикстурами и плагинами.
# tests/bdd/test_refund_steps.py
from decimal import Decimal
import pytest
from pytest_bdd import scenarios, given, when, then, parsers
from refund import Order, calculate_refund
# Подключаем все сценарии из файла — по одному pytest-тесту на строку Examples.
scenarios("features/refund.feature")
@pytest.fixture
def context() -> dict:
"""Общее состояние сценария. Отдельная фикстура вместо глобальных переменных."""
return {}
@given(parsers.parse('заказ на {amount:d} рублей со статусом "{status}"'), target_fixture="order")
def _order(amount: int, status: str, context: dict) -> Order:
context["shipped"] = status == "отгружен"
return Order(total=Decimal(amount), shipped=context["shipped"], days_since_purchase=0)
@given(parsers.parse("с момента покупки прошло {days:d} дней"), target_fixture="order")
def _order_aged(days: int, order: Order) -> Order:
# dataclass frozen — возвращаем копию, состояние не мутируем.
return Order(total=order.total, shipped=order.shipped, days_since_purchase=days)
@when("покупатель отменяет заказ")
def _cancel(order: Order, context: dict) -> None:
# Шаг НЕ содержит проверок — только действие. Это важно.
context["refund"] = calculate_refund(order)
@then(parsers.parse("к возврату полагается {expected:d} рублей"))
def _check(expected: int, context: dict) -> None:
assert context["refund"] == Decimal(expected)
Обратите внимание: шаги — тонкие. В них нет ни if, ни циклов, ни вычислений. Как только в step definition
появляется логика, спецификация начинает проверять саму себя, а не систему.
TypeScript: Cucumber.js против домена, а не против UI
// features/support/steps.ts — @cucumber/cucumber + playwright только там, где он нужен
import { Given, When, Then, Before } from '@cucumber/cucumber';
import assert from 'node:assert/strict';
import { ApiClient } from './api-client';
interface World {
api: ApiClient;
orderId: string;
refund: number;
}
Before(async function (this: World) {
// Изолированный тенант на сценарий: параллельный прогон не ловит гонки за данные.
this.api = await ApiClient.forFreshTenant();
});
Given('заказ на {int} рублей со статусом {string}', async function (this: World, amount: number, status: string) {
// Состояние готовим через API, а не кликами. Быстро и детерминированно.
const order = await this.api.seedOrder({ total: amount, shipped: status === 'отгружен' });
this.orderId = order.id;
});
Given('с момента покупки прошло {int} дней', async function (this: World, days: number) {
// Управляемое время вместо ожидания: сервис умеет принимать смещение часов в тестовом режиме.
await this.api.setClockOffsetDays(days);
});
When('покупатель отменяет заказ', async function (this: World) {
const res = await this.api.cancelOrder(this.orderId);
this.refund = res.refundAmount;
});
Then('к возврату полагается {int} рублей', function (this: World, expected: number) {
assert.equal(this.refund, expected, `ожидали возврат ${expected}, получили ${this.refund}`);
});
Ключевое архитектурное решение показано на схеме ниже: к какому слою цеплять шаги. Подавляющее большинство провальных BDD-внедрений цепляет их к UI, получает хрупкость и медленный прогон, а потом делает вывод, что «BDD не работает».
Практический ориентир: из десяти сценариев девять должны исполняться против API или прямо против домена, и лишь один-два — через браузер, ровно там, где предметом требования является сам интерфейс. Это тот же принцип, что в стратегии автоматизации: уровень исполнения выбирается по самому дешёвому месту, где риск ещё виден.
8. Двойной цикл: как TDD и BDD стыкуются
Классическая схема outside-in: внешний цикл — приёмочный сценарий, внутренний — обычный red-green-refactor. Внешний остаётся красным всё время, пока идут внутренние циклы, и это нормально.
на языке домена] A2 --> A3{Красный?} A3 -->|да, ожидаемо| B1 A3 -->|зелёный сразу| A4[Подозрительно: тест ничего не проверяет] end subgraph INNER["Внутренний цикл — юнит, секунды-минуты"] B1[Пункт из списка тестов] --> B2[Красный юнит-тест] B2 --> B3[Минимальный код] B3 --> B4[Рефакторинг на зелёном] B4 --> B5{Хватает для
приёмочного?} B5 -->|нет| B1 end B5 -->|да| C1[Прогон приёмочного] C1 --> C2{Зелёный?} C2 -->|нет| B1 C2 -->|да| C3[Рефакторинг верхнего уровня] C3 --> A1 style OUTER stroke:#4caf82,stroke-width:2px style INNER stroke:#4a8fd4,stroke-width:2px
Практика: держать один и только один красный приёмочный тест. Два одновременно красных внешних цикла
означают, что вы делаете две истории сразу и потеряли фокус. В CI такие тесты помечаются тегом @wip
и исключаются из основного набора — иначе сборка перманентно красная и перестаёт что-либо значить.
Терминологическая развязка, которая экономит много спора на ретро:
- TDD — техника разработчика, юнит-уровень, внутренний цикл, язык кода.
- ATDD — приёмочные тесты пишутся до реализации; про порядок, не про синтаксис.
- BDD — практика совместного выяснения требований на примерах; может использовать ATDD как способ автоматизации, а может не использовать вообще.
- Cucumber/Gherkin — инструмент и язык. К BDD относятся так же, как Jira к Agile: часто встречаются вместе, но одно не подразумевает другого.
9. Как это выглядит в CI
Разные слои — разные гейты и разное время. Двойной цикл диктует и структуру пайплайна: быстрый набор на каждый коммит, приёмочные — после сборки образа.
# .github/workflows/tests.yml
name: tests
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements-dev.txt
# Внутренний цикл: должен укладываться в 2 минуты, иначе разработчик перестанет его гонять локально.
- run: pytest tests/unit --maxfail=1 -q --durations=10
# Дифф-покрытие вместо общего процента: гейт на НОВЫЙ код, а не на весь репозиторий.
- run: |
pytest tests/unit --cov=src --cov-report=xml -q
diff-cover coverage.xml --compare-branch=origin/main --fail-under=80
acceptance:
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- run: docker compose -f docker-compose.test.yml up -d --wait
# Внешний цикл: сценарии против API. @wip исключаем — это незавершённые истории.
- run: pytest tests/bdd -q -m "not wip" -n 4
# Живая документация публикуется только из зелёного прогона на main.
- if: github.ref == 'refs/heads/main'
run: |
pytest tests/bdd --cucumber-json=report.json -q -m "not wip"
python tools/publish_living_docs.py report.json
Два момента, которые чаще всего забывают. Первый: дифф-покрытие вместо общего процента. Общий процент — метрика-самообман, подробно разобранная в статье про CI; порог на изменённые строки заставляет покрывать новый код, не требуя героического похода в легаси. Второй: живая документация публикуется только из зелёного прогона, иначе бизнес читает спецификацию, которая не соответствует системе, — и это хуже, чем отсутствие документации вовсе.
10. Честно про боль
«Мы делаем TDD» как ритуал. Самый частый способ имитации: код пишется первым, тесты дописываются следом и подгоняются под реализацию, а в PR пишется «по TDD». Такие тесты по определению зелёные с первого прогона и по построению не могут поймать ничего — они кодируют то, что код делает, включая баги. Диагностика на ревью: посмотрите историю коммитов. Если тест и реализация приехали одним коммитом на 600 строк, цикла не было. Лечится не запретами, а маленькими PR.
Тесты, которые ломаются от рефакторинга. Симптом: любое переименование метода роняет полсотни тестов. Причина всегда одна — тестирование структуры вместо поведения, обычно через избыточные моки. Мера: если для настройки теста нужно больше трёх дублёров, проблема в дизайне тестируемого кода, а не в тесте.
Gherkin, который никто не читает. Проверяется одним вопросом на ретро: назовите живого человека вне
команды разработки, который за последний квартал открывал .feature-файл. Если такого нет — вы платите
за слой склейки, регулярки и ранер, получая взамен только отчёт. Это не значит, что надо всё выбросить:
это значит, что честнее переписать сценарии в обычные параметризованные тесты и оставить Example Mapping,
который и давал всю пользу.
Взрыв шагов. Через год живут 400 step definitions, половина дублирует другую половину с точностью
до формулировки, при добавлении сценария проще написать новый шаг, чем найти существующий. Профилактика:
cucumber --dry-run или pytest --collect-only в CI на неиспользуемые шаги, единый словарь фраз в
docs/glossary.md, ревью новых шагов отдельным пунктом чек-листа.
Приёмочные тесты как замена всему. Команда с воодушевлением пишет сотню сценариев через UI, прогон занимает сорок минут, флаки съедают доверие, и через полгода набор отключают. Это ровно та перевёрнутая пирамида, о которой в стратегии автоматизации. Сценарии BDD — верхушка, их должны быть десятки, а не сотни.
TDD не заменяет тест-дизайн. Разработчик, пишущий тесты первыми, покрывает случаи, о которых подумал. Он не подумает про эмодзи в имени, про параллельную отмену того же заказа из двух вкладок и про пользователя в часовом поясе UTC+13. Это ровно та зона, где нужен профессиональный тестировщик и исследовательское тестирование (ручное тестирование). Команды, считающие, что «у нас TDD, тестировщик не нужен», регулярно узнают об этом дорого.
11. Как внедрять, если сейчас ничего этого нет
Порядок важен, потому что попытка начать с инструмента проваливается почти всегда.
- Начните с Example Mapping, а не с TDD и не с Cucumber. Одна встреча на 25 минут по одной истории перед спринтом. Ничего не автоматизируйте. Считайте красные карточки — это ваш видимый результат, найденные заранее непонимания.
- Введите дисциплину маленького шага без лозунга «сначала тест». Договоритесь: между двумя зелёными прогонами не больше пятнадцати минут. Это то самое действующее вещество из исследований Fucci, и оно не вызывает сопротивления.
- Пробуйте тест-первый там, где он даёт мгновенную выгоду: чистая логика, ветвления, деньги, права, парсинг, любой баг-фикс. Правило «на каждый баг сначала падающий тест» — самая продаваемая часть TDD, потому что польза очевидна всем: тест доказывает, что вы воспроизвели проблему, и гарантирует, что она не вернётся.
- Автоматизируйте сценарии только тогда, когда
.featureкто-то читает. До этого момента шаги — чистые издержки. - Цепляйте шаги к API. UI — исключение, а не умолчание.
- Поставьте гейты в CI: дифф-покрытие, тег
@wip, публикация живой документации только с зелёного main.
Мини-итог
- Действующее вещество TDD — дисциплина маленького шага, а не порядок «тест до кода»: это то, что реально показали контролируемые эксперименты. Мерьте время между зелёными прогонами, а не веру.
- Красная фаза обязана падать на ассерте; зелёная фаза не обязана быть красивой; рефакторинг делается только на зелёном и не добавляет поведения.
- Промышленные данные Microsoft/IBM: минус 40–90% плотности дефектов ценой плюс 15–35% времени разработки. Это сделка, а не бесплатный обед, и заключать её надо там, где цена дефекта высока.
- Школа TDD выбирается по слою: детройтская в домене, лондонская в координаторах. Моки в доменном ядре — прямой путь к тестам, которые ломаются от каждого рефакторинга.
- TDD не работает там, где ответ неизвестен заранее. Легальные ответы: spike & stabilize, характеризующие тесты, визуальная регрессия.
- BDD — это разговор, а не Cucumber. Вся экономия в фазе Discovery; Example Mapping даёт её за 25 минут и без единой строки кода.
- Gherkin пишется декларативно, на языке предметной области, без кликов и селекторов. Шаги — тонкие, без логики. Девять сценариев из десяти исполняются против API.
- Если
.featureне читает никто вне команды разработки — слой Gherkin честнее выбросить и оставить обычные параметризованные тесты. Практику Example Mapping при этом сохранить. - TDD покрывает то, о чём разработчик подумал. Всё остальное по-прежнему находит тестировщик.
Что стоит прочитать целиком: Кент Бек, «Test-Driven Development: By Example» — короткая книга, читается за вечер; Стив Фримен и Нат Прайс, «Growing Object-Oriented Software, Guided by Tests» — лондонская школа из первых рук; Гойко Аджич, «Specification by Example»; Себ Роуз и Гашпар Надь, «The BDD Books: Discovery» (bddbooks.com); Дэн Норт, «Introducing BDD»; Мартин Фаулер, серия «Is TDD Dead?»; Майкл Физерс, «Working Effectively with Legacy Code» — про то, как внедрить всё вышеописанное в код, который этого не ждал.
Что дальше
Мы прошли весь технический контур профессии: от принципов и тест-дизайна до автоматизации, CI и практик разработки через тесты. Остался последний вопрос, который важнее любого инструмента, — что со всем этим делать с точки зрения собственной карьеры: какие роли существуют, чем отличается middle от senior, что спрашивают на собеседованиях и куда расти дальше, когда автоматизация перестала быть вызовом.
Профессия тестировщика: роли, грейды, собеседования, развитие