TDD и BDD на практике: red-green-refactor, outside-in и цена Gherkin

TDD — это техника дизайна через обратную связь, а не про 100% покрытие; BDD — общий язык примеров, а не «TDD с Cucumber». Разбираем цикл red-green-refactor, школы Detroit и London, BDD как спеку через примеры и честную цену Gherkin — на живом Go-стенде с реальными прогонами и mutation-тестированием

Про 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, BDD и mutation — цикл red→green→refactor (RED «got 0, want 5000» → GREEN → шестерёнки рефактора, «сначала тест»); BDD-спека на Gherkin (Дано/Когда/Тогда) через regex-клей к step-definitions, весы «BDD 117 строк ⇄ table 34»; mutation — убитый мутант >=→> против выжившего на границе, эффективность 100% против 71% при 100% покрытии операторов; указатель «TDD — как пишешь код, BDD — как договариваешься о поведении»

В статье

Что путают

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; godog v0.15.1, gremlins v0.6.0.
  • Оговорка: числа efficacy и объёма кода машинонезависимы (это счётные величины), но зависят от версий инструментов и конкретного кода — набор мутаторов и efficacy привязаны к gremlins v0.6.0; на другой версии инструмента результат может отличаться. Воспроизводите на своей сборке.
  • Смежные статьи: тестирование across языков (пирамида, дублёры, property-based), тестирование распределённых систем (интеграционный слой), fuzzing и mutation-тестированиеСкоро (качество тестов вглубь), тестирование в Go (инструментальная база).

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии