Test-driven development описав Кент Бек (Kent Beck) у книжці «Test-Driven Development: By Example» (2002). Цикл має три кроки. Red: написати маленький тест на одну нову поведінку й побачити, що він падає, причому з очікуваної причини. Green: найпростішим кодом зробити тест зеленим, не думаючи поки про красу. Refactor: прибрати дублювання, дати назви, винести константи, поки всі тести зелені. Потім наступний тест, і цикл повторюється кожні кілька хвилин.
Червоний крок здається формальністю, але саме він робить тест надійним. Тест, який ніколи не був червоним, може нічого не перевіряти: асерт порівнює значення сам із собою, тест не той файл, метод названо з одруківкою. Побачивши падіння «очікували 0, отримали 50», ви знаєте, що тест дивиться на правильну річ. Рефакторинг теж не можна пропускати: без нього «найпростіший код» з кожним циклом обростає частковими випадками.
Найбільше TDD дає як фідбек на дизайн. Якщо перший тест на клас вимагає піднімати контейнер, базу й пів десятка моків, це видно ще до того, як написано код, і клас одразу отримує залежності через конструктор, час параметром і вузькі інтерфейси. Тому TDD природно веде до чистих доменних обʼєктів, які легко перевірити.
Існують дві школи. Класична (її звуть Detroit або Chicago) іде зсередини назовні: починає з доменних обʼєктів, бере справжніх сусідів і перевіряє стан. Лондонська, описана Стівом Фріменом і Натом Прайсом у «Growing Object-Oriented Software, Guided by Tests», іде ззовні всередину: приймальний тест на сценарій, потім клас верхнього рівня, а ще неіснуючих сусідів підміняють моками, щоб відкрити їхні інтерфейси. У Laravel-проєкті поширений гібрид: червоний feature-тест сценарію зверху і класичні TDD-цикли для домену під ним.
У TDD є чесні межі. Для дослідження незнайомого API чи бібліотеки роблять спайк, прототип без тестів, і викидають його. Верстку, склеювальний код і конфігурацію вигідніше перевіряти feature-тестами після. А найдешевша точка входу в TDD у будь-якому проєкті це баги: спершу тест, що відтворює баг і падає, потім виправлення.
// Крок 1, red: тест на поведінку, якої ще немає.
// Спершу падає, бо класу Delivery немає. Із заглушкою `return 50;`
// падає вже на 2000 гривнях: це і є очікувана причина.
it('is free from 2000 hryvnias', function (int $sum, int $price) {
expect((new Delivery)->price($sum))->toBe($price);
})->with([
'на гривню менше' => [1999, 50],
'рівно на порозі' => [2000, 0],
]);
// Крок 2, green: найпростіший код, що проходить тест.
final class Delivery
{
public function price(int $sum): int
{
return $sum >= 2000 ? 0 : 50;
}
}
// Крок 3, refactor: на зеленому прибираємо магічні числа.
// Тест не змінюється, і саме він гарантує, що поведінка та сама.
final class DeliveryRefactored
{
private const FREE_FROM = 2000;
private const PRICE = 50;
public function price(int $sum): int
{
return $sum >= self::FREE_FROM ? 0 : self::PRICE;
}
}
// Далі новий цикл: наступний тест на наступне правило.
Опишіть один реальний цикл з вашої практики: «правило про поріг безкоштовної доставки: спершу тест на 1999 і 2000, червоний, бо методу немає; потім `return $sum >= 2000 ? 0 : 50`; потім рефакторинг, винесли поріг у константу». Конкретний цикл звучить переконливіше за визначення, а слова «спершу побачив, що тест падає з правильної причини» показують розуміння сенсу.