Про TDD и BDD спорят так, будто это идеологии. На деле обе — инструменты с узким назначением, которое легко потерять за словами. TDD путают с «писать много тестов» и с погоней за покрытием; BDD — с фреймворком Cucumber и его .feature-файлами. В обоих случаях подменяют суть формой: TDD — это техника дизайна через немедленную обратную связь, а BDD — способ описать поведение примерами на общем языке. Ни то, ни другое не про «тесты ради тестов».
Эта статья разводит смыслы и показывает обе практики на одной и той же фиче — объёмной скидке на корзину. Всё заземлено на живой стенд digital-cookbook/testing/tdd-bdd/: реальный цикл red-green-refactor с настоящими прогонами go test, тот же расчёт через godog (Gherkin), и mutation-тестирование, которое проверяет, а ловят ли тесты вообще баги. Go, копейки в int64, числа сверены на go1.26.3.
В статье
- Что путают
- Цикл TDD: red → green → refactor
- Две школы: Detroit и London
- Что TDD даёт и чего не даёт
- BDD как спека через примеры
- Когда BDD окупается, а когда вырождается
- Проверка качества тестов: mutation
- Синтез: что где
- Демо и версии
- Документация и первоисточники
Что путают
TDD (test-driven development) — это не «сначала весь код, потом тесты» и не «покрыть всё на 100%». Это цикл, в котором тест пишется до кода и управляет дизайном: сначала формулируешь ожидание (тест падает — red), потом пишешь минимум, чтобы он прошёл (green), потом улучшаешь код, не ломая тестов (refactor). Тесты здесь — побочный, хотя и ценный, продукт; главный — давление на дизайн: код, который тяжело протестировать, почти всегда плохо спроектирован, и TDD подсвечивает это сразу, а не на code review через неделю.
BDD (behavior-driven development) страдает от трёх разных пониманий, которые стоит развести:
- BDD как инструмент — Cucumber/godog и
.feature-файлы на языке Gherkin. Это самое узкое и самое обманчивое понимание: инструмент без остального смысла превращается в дорогой синтаксис поверх обычных тестов. - BDD как стиль формулировки — описывать поведение через конкретные примеры в форме «Дано / Когда / Тогда» (Given/When/Then) на языке, понятном не только разработчику.
- BDD как процесс — разговор до кода («три амиго»: бизнес, разработка, тестирование), где примеры вырабатываются совместно и становятся одновременно спецификацией и приёмочными тестами.
Ценность BDD — во втором и третьем смыслах. Первый (инструмент) сам по себе не даёт ничего: если сценарии на Gherkin никто, кроме команды, не читает, вы получили те же тесты, но дороже. К этому вернёмся в разделе про цену Gherkin.
Одно короткое разграничение, которое снимает половину споров: TDD — про то, как ты пишешь код (техника разработчика); BDD — про то, как команда договаривается о поведении (язык и процесс). Они не конкурируют и часто живут вместе: BDD задаёт приёмочные примеры сверху, TDD ведёт реализацию юнитов снизу.
Цикл TDD: red → green → refactor
Разберём цикл на фиче стенда — объёмной скидке на корзину: при сумме от 100.00 применяется 5%, от 200.00 — 10%. Строим её test-first, тремя итерациями. Вся арифметика в копейках (int64), чтобы не ловить погрешности float.
Цикл 1 — сумма без скидки. Сначала тест на то, что корзина суммирует позиции. Запускаем — он падает (red), потому что реализация ещё заглушка:
--- FAIL: TestTotal_NoDiscountBelowThreshold
pricing_test.go:15: got 0, want 5000Пишем минимум — суммирование — и тест зеленеет (green). Никакой скидки пока: её ещё не потребовал ни один тест.
Цикл 2 — 5% от 100.00. Добавляем тест на скидку. Он падает: скидки в коде нет, возвращается полная сумма.
--- FAIL: TestTotal_FivePercentFromHundred
pricing_test.go:23: got 15000, want 14250Добавляем ветку if subtotal >= 10000 — green.
Цикл 3 — 10% от 200.00. Ещё тест. Здесь red особенно показателен: срабатывает старый тир 5% и даёт 23750 вместо ожидаемых 22500 — видно, что новое правило действительно отсутствует, а не «случайно проходит».
--- FAIL: TestTotal_TenPercentFromTwoHundred
pricing_test.go:31: got 23750, want 22500Добавляем тир 10% — green. И теперь, под защитой трёх зелёных тестов, делаем refactor: выносим оба тира в таблицу, чтобы добавление третьего не плодило if-ы. Тесты остаются зелёными — это и есть смысл сети: рефакторинг безопасен, потому что поведение зафиксировано.
type tier struct {
MinCents int64
Percent int64
}
// tiers отсортированы по убыванию порога: берём первый подходящий.
var tiers = []tier{
{MinCents: 20000, Percent: 10}, // >= 200.00 → 10%
{MinCents: 10000, Percent: 5}, // >= 100.00 → 5%
}
func Total(items []Item) int64 {
var subtotal int64
for _, it := range items {
subtotal += it.PriceCents * it.Qty
}
return subtotal - discount(subtotal)
}
func discount(subtotal int64) int64 {
for _, t := range tiers {
if subtotal >= t.MinCents {
return subtotal * t.Percent / 100
}
}
return 0
}Финальный шаг — свести инкрементальные тесты в один table-driven с явными границами тиров (99.99 / 100.00 / 199.99 / 200.00). И тут тест поймал ошибку не в коде, а в моём ожидании: 19999 * 5 / 100 = 999.95, но целочисленное деление усекает скидку до 999 копеек, поэтому итог — 19000, а не 18999, как я написал. Мелочь, но показательная: тест заставил принять решение об округлении явно (усечение в пользу магазина), а не оставить его случайным. Так TDD и работает — не «проверяет готовый код», а вытаскивает решения, которые иначе приняли бы молча.
Две школы: Detroit и London
Внутри TDD есть давнее расхождение — что именно проверять.
Detroit-стиль (он же классический, или state-based): тест проверяет результат — состояние или возвращаемое значение. Наш TestTotal — ровно такой: подали корзину, сверили итог. Зависимости используются настоящие, дублёры — только там, где иначе никак (внешняя сеть, время, случайность). Плюс — тесты не знают о внутреннем устройстве и переживают рефакторинг; минус — крупные модули тянут за собой реальные зависимости.
London-стиль (mockist, он же outside-in): тест проверяет взаимодействия — что объект вызвал нужные методы своих зависимостей, — а сами зависимости заменены моками. Разработка идёт снаружи внутрь: начинаешь с внешнего сценария, мокаешь ещё не написанных соавторов, потом спускаешься и реализуешь их. Плюс — дизайн через интерфейсы и раннее выделение ролей; минус — тесты привязаны к структуре вызовов, и безобидный рефакторинг (переставил порядок вызовов, объединил два метода) их ломает, хотя поведение не изменилось.
Практический ориентир: Detroit по умолчанию — тесты дешевле в поддержке и не мешают менять внутренности. London — там, где важен именно протокол взаимодействия (координатор, вызывающий сервисы в определённом порядке; код, где побочный эффект и есть результат — отправка события, запись в очередь). Мокать стоит границы (сеть, БД, часы), а не каждый внутренний объект: чем больше моков, тем сильнее тест повторяет реализацию и тем меньше проверяет поведение. Про разницу мока, стаба и фейка — в статье про тестирование across языков.
Что TDD даёт и чего не даёт
Чтобы TDD не превратился в культ, полезно держать в голове его границы.
Даёт:
- Обратную связь по дизайну. Трудно написать тест — сигнал, что у кода плохие швы: слишком много зависимостей, скрытое состояние, смешанные ответственности. Это самое ценное и часто недооценённое.
- Регрессионную сеть. Каждый пройденный цикл оставляет тест, который держит поведение при будущих правках — как мы видели на рефакторе тиров.
- Документацию примерами. Хорошо названные тесты читаются как спецификация: что система делает на таких-то входах.
Не даёт:
- Не заменяет интеграционные и e2e-тесты. Юнит-тесты проверяют логику в изоляции; что модули стыкуются, что миграция применяется, что запрос долетает до БД — отдельный слой (Testcontainers и подобное), см. тестирование распределённых систем.
- Не гарантирует отсутствие багов. Тест проверяет то, о чём ты подумал. Про то, о чём не подумал, расскажут property-based тесты, фаззинг и mutation — не TDD.
- Не равно 100% покрытию. Покрытие — индикатор, а не цель. Можно иметь 100% операторов (statements) и слабые проверки (покажем это ниже буквально). Гнаться за последними процентами покрытия — почти всегда трата времени на тесты геттеров вместо тестов логики.
BDD как спека через примеры
Теперь тот же расчёт — но описанный как поведение на общем языке. Gherkin структурирует пример в «Дано / Когда / Тогда»: предусловие, действие, ожидаемый результат. godog (BDD-движок для Go) исполняет .feature-файл как тест. Локализация встроена — можно писать по-русски.
# language: ru
Функция: Объёмная скидка на корзину
Чтобы увеличить средний чек,
Как магазин,
Мы хотим поощрять крупные заказы объёмной скидкой.
Сценарий: Мелкий заказ идёт без скидки
Дано пустая корзина
Когда я добавляю товар за 50.00 в количестве 1
Тогда итог корзины равен 50.00
Структура сценария: Границы тиров скидки
Дано пустая корзина
Когда я добавляю товар за <цена> в количестве 1
Тогда итог корзины равен <итог>
Примеры:
| цена | итог |
| 99.99 | 99.99 |
| 100.00 | 95.00 |
| 199.99 | 190.00 |
| 200.00 | 180.00 |
| 250.00 | 225.00 |Текст фичи не исполняется сам — его связывают с кодом step-definitions: по регулярному выражению на каждый шаг. Это тот самый «клей», которого в обычном тесте нет:
func InitializeScenario(sc *godog.ScenarioContext) {
c := &cart{}
// Before обнуляет состояние перед каждым сценарием — иначе шаги одного
// сценария видели бы корзину предыдущего.
sc.Before(func(ctx context.Context, _ *godog.Scenario) (context.Context, error) {
c.items = nil
return ctx, nil
})
sc.Step(`^пустая корзина$`, c.anEmptyCart)
sc.Step(`^я добавляю товар за ([\d.]+) в количестве (\d+)$`, c.iAddAnItem)
sc.Step(`^итог корзины равен ([\d.]+)$`, c.theCartTotalIs)
}Плюс нужен переводчик человеческого «100.00» в копейки, сравнение, состояние корзины между шагами. Запуск через обычный go test:
Функция: Объёмная скидка на корзину
Сценарий: Мелкий заказ идёт без скидки
Дано пустая корзина
Когда я добавляю товар за 50.00 в количестве 1
Тогда итог корзины равен 50.00
...
6 scenarios (6 passed)
18 steps (18 passed)Читается как спецификация: даже человек, не знающий Go, поймёт, что проверяется. В этом и обещание BDD — примеры как живой, исполняемый договор о поведении.
Когда BDD окупается, а когда вырождается
А теперь честно про цену. Закодируем те же пять граничных примеров обычным table-driven тестом Go — без .feature, без регэкспов, без парсинга строк:
// пакет bdd, импортирует "tech.khorost/tdd-bdd-cookbook/tdd"
func TestBoundariesTableDriven(t *testing.T) {
cases := []struct {
name string
priceCents int64
wantCents int64
}{
{"99.99 — без скидки", 9999, 9999},
{"100.00 — 5%", 10000, 9500},
{"199.99 — 5% с усечением", 19999, 19000},
{"200.00 — 10%", 20000, 18000},
{"250.00 — 10%", 25000, 22500},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := tdd.Total([]tdd.Item{{PriceCents: c.priceCents, Qty: 1}})
if got != c.wantCents {
t.Errorf("Total(%d) = %d, want %d", c.priceCents, got, c.wantCents)
}
})
}
}Сравним объём на стенде (сверено):
| Способ | Строк | Слои |
|---|---|---|
| BDD (godog) | 117 | discount.feature (23) + step-definitions/runner (94) |
| table-driven | 34 | один _test.go, без клея |
Одинаковое покрытие, в ~3.4 раза больше кода. И это только код: у Gherkin есть ещё когнитивная цена — регэкспы шагов, состояние между ними, необходимость держать текст и step-definitions в синхроне. Каждый новый шаг — новая функция и новый регэксп.
Отсюда честный критерий. BDD-инструмент окупается, только если сценарии реально читают или пишут не-разработчики — продакт, аналитик, QA, заказчик, — и .feature служит общим договором до кода. Тогда ~3.4× — это плата за язык, на котором говорят обе стороны, и она оправдана.
Если же .feature-файлы пишет и читает только команда разработки, BDD-инструмент вырождается в дорогой синтаксис поверх обычных тестов: вы платите за Gherkin, но не получаете его единственного преимущества — общего языка с бизнесом. В этом случае те же примеры дешевле и яснее выразить table-driven тестом с говорящими именами. Стиль «примерами» (Given-When-Then как способ думать о тесте) можно оставить — он бесплатный; а вот тяжёлый инструмент — нет.
Правило простое: берёте Cucumber/godog ради живого документа для бизнеса — да; ради красивых тестов «для себя» — почти наверняка нет.
Проверка качества тестов: mutation
TDD оставляет тесты — но насколько они придирчивы? Покрытие на этот вопрос не отвечает: строка выполнилась ≠ ошибка в ней была бы замечена. Отвечает mutation-тестирование: инструмент вносит в код мелкие поломки (мутанты) — меняет >= на >, * на /, - на + — и смотрит, упадёт ли хоть один тест. Упал — мутант «убит», тесты придирчивы. Не упал — мутант «выжил», в проверках дыра.
Прогоним gremlins по нашему TDD-набору и по нарочно ослабленному (без граничных примеров, только серединные 150/250). Оба набора дают 100% покрытие операторов (go test -cover печатает 100.0% of statements) — но результат разный (сверено):
| Набор тестов | Killed | Lived | Efficacy | Покрытие (statements) |
|---|---|---|---|---|
| сильный (TDD, с границами) | 7 | 0 | 100% | 100% |
| слабый (без границ) | 5 | 2 | 71.43% | 100% |
Слабый набор пропустил двух мутантов, и оба поучительны:
CONDITIONALS_BOUNDARY(>=→>) выжил — потому что нет теста на точную границу тира. Примеры 150 и 250 лежат в середине диапазонов и остаются в тех же тирах даже после подмены; а вот тест ровно на100.00сразу бы упал (100 > 100ложно → скидки нет). Границы ловят граничные мутанты.ARITHMETIC_BASE(*→/вPriceCents * Qty) выжил — потому что все примеры слабого набора используютQty = 1, аprice * 1 == price / 1. Мутант незаметен, пока нет позиции сQty ≠ 1.
Сильный TDD-набор оба ловит: в нём есть тест ровно на 100.00/200.00 и позиция с Qty = 2. Вот что показывает mutation, а покрытие — нет: достаточно ли тесты дотошны, а не просто «прошлись ли по операторам». Подробнее про фаззинг и mutation как способ искать то, о чём не подумал, — в статье про fuzzing и mutation-тестированиеСкоро.
Оговорка: mutation-тестирование медленное (это прогон всего набора на каждый мутант). Гонять его стоит точечно по ключевой логике или в ночном CI, а не на каждый push.
Синтез: что где
Собрать всё в рабочую практику проще всего по слоям.
- Юнит-логика → TDD (Detroit по умолчанию). Red-green-refactor как способ проектировать и держать регрессионную сеть. London-стиль — точечно, где важен протокол взаимодействия.
- Приёмка → стиль «примерами», инструмент BDD по потребности. Given-When-Then как способ формулировать — всегда полезно и бесплатно. Тяжёлый Gherkin/Cucumber — только когда
.featureреально читает бизнес; иначе table-driven с ясными именами. - Стыки → интеграционные тесты. Testcontainers, реальные БД и брокеры — то, куда TDD и BDD-юниты не достают (см. тестирование распределённых систем).
- Качество самих тестов → mutation и property-based. Проверить, что сеть придирчива, а не просто велика по покрытию.
Ни TDD, ни BDD не серебряная пуля. TDD — дисциплина письма кода, которая окупается обратной связью по дизайну. BDD — язык договора о поведении, который окупается ровно тогда, когда договор нужен обеим сторонам. Спутать инструмент с ценностью — и вы получите дорогие ритуалы; разделить их — и каждый инструмент встанет на своё место.
Демо и версии
- Код: живой стенд
digital-cookbook/testing/tdd-bdd/— одна фича (объёмная скидка), три подхода:tdd/(red-green-refactor + table-driven),bdd/(godog + table-контраст),mutation/(слабый набор для демонстрации выжившего мутанта). Код в тексте — выжимки. - Как воспроизвести:
go test ./...(весь модуль зелёный),go test -v ./bdd/(сценарии godog),go install github.com/go-gremlins/gremlins/cmd/gremlins@v0.6.0 && gremlins unleash --timeout-coefficient 10 ./tdd/(100% efficacy) иgremlins unleash --timeout-coefficient 10 ./mutation/(71.43%, 2 мутанта выжили). Флаг--timeout-coefficient 10обязателен: без него на медленной машине мутанты уходят в TIMED OUT и efficacy падает до 0% — не потому что тесты плохи, а потому что базовый таймаут слишком мал. Проверено на go1.26.3; godogv0.15.1, gremlinsv0.6.0. - Оговорка: числа efficacy и объёма кода машинонезависимы (это счётные величины), но зависят от версий инструментов и конкретного кода — набор мутаторов и efficacy привязаны к gremlins
v0.6.0; на другой версии инструмента результат может отличаться. Воспроизводите на своей сборке. - Смежные статьи: тестирование across языков (пирамида, дублёры, property-based), тестирование распределённых систем (интеграционный слой), fuzzing и mutation-тестированиеСкоро (качество тестов вглубь), тестирование в Go (инструментальная база).
Документация и первоисточники
- Kent Beck, Test-Driven Development: By Example — исходная формулировка цикла red-green-refactor.
- Martin Fowler, Mocks Aren’t Stubs — разбор Detroit vs London и разницы дублёров.
- Dan North, Introducing BDD — откуда взялся BDD и зачем «Given-When-Then».
- Cucumber / Gherkin Reference и godog — синтаксис фич и BDD-движок для Go.
- gremlins — mutation-тестирование для Go: мутаторы, efficacy, coverage.
Комментарии