TDD: red-green-refactor

TDD на Python: цикл red-green-refactor на живом примере.

TDD (Test-Driven Development) — подход, при котором сначала пишут падающий тест, затем минимальный код, чтобы он прошёл, и только потом улучшают код.

Цикл из трёх шагов

  1. Red (красный). Пишем тест на ещё не существующее поведение — он падает. Это нормально: тест задаёт цель.
  2. Green (зелёный). Пишем минимальный код, чтобы тест прошёл. Не больше, чем нужно.
  3. Refactor (рефакторинг). Улучшаем код, не меняя поведения. Тесты держат нас в безопасности — если что-то сломаем, увидим сразу.

Затем цикл повторяется для следующего требования. Тесты ведут разработку — отсюда и название.

Зачем писать тест первым

  • Вы сначала думаете о поведении (что функция должна делать), а не о реализации.
  • Гарантированно нет непротестированного кода — тест существовал до кода.
  • Тест точно проверяет нужное: вы видели, как он падает, значит он работает.

Живой пример: функция roman() — арабские в римские

Будем строить функцию по шагам. Шаг Red: пишем тесты на ещё не написанную логику. Чтобы увидеть «красный» прямо в браузере, дадим заведомо неполную заготовку:

import unittest

def roman(n):
    return ""   # пока заглушка — тесты должны упасть

class TestRoman(unittest.TestCase):
    def test_one(self):
        self.assertEqual(roman(1), "I")
    def test_four(self):
        self.assertEqual(roman(4), "IV")
    def test_nine(self):
        self.assertEqual(roman(9), "IX")

unittest.main(argv=[''], exit=False, verbosity=2)

Вывод: (Red — тесты падают, это ожидаемо; устойчивая концовка)

... FAIL
... FAIL
... FAIL

----------------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=3)

Три падения — цель поставлена. Теперь шаг Green: пишем минимальную реализацию, которая делает тесты зелёными.

import unittest

def roman(n):
    table = [
        (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
        (100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
        (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
    ]
    result = ""
    for value, symbol in table:
        while n >= value:
            result += symbol
            n -= value
    return result

class TestRoman(unittest.TestCase):
    def test_one(self):
        self.assertEqual(roman(1), "I")
    def test_four(self):
        self.assertEqual(roman(4), "IV")
    def test_nine(self):
        self.assertEqual(roman(9), "IX")
    def test_complex(self):
        self.assertEqual(roman(58), "LVIII")   # 50 + 5 + 1 + 1 + 1
    def test_big(self):
        self.assertEqual(roman(1994), "MCMXCIV")

unittest.main(argv=[''], exit=False, verbosity=2)

Вывод: (Green — все тесты проходят)

test_big (__main__.TestRoman.test_big) ... ok
test_complex (__main__.TestRoman.test_complex) ... ok
test_four (__main__.TestRoman.test_four) ... ok
test_nine (__main__.TestRoman.test_nine) ... ok
test_one (__main__.TestRoman.test_one) ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.000s

OK

Шаг Refactor: код уже компактен, но мы могли бы, например, вынести таблицу в константу модуля. И вот ключевое: пока тесты зелёные, любой такой рефакторинг безопасен — сломаем поведение, тесты тут же покраснеют.

Ритм TDD

ШагЧто делаемСостояние тестов
Redпишем тест на новое поведениекрасные (падают)
Greenминимальный код «лишь бы прошло»зелёные
Refactorулучшаем код, не меняя поведенияостаются зелёными

Почему «минимальный» код на шаге Green

Новичков смущает совет писать минимальный код, лишь бы тест прошёл, — кажется, что это халтура. Но в этом и смысл TDD: код двигают вперёд только тесты. Каждый новый тест задаёт следующее требование, и вы реализуете ровно его, не больше. Это защищает от двух бед: от лишнего кода «на всякий случай», который никто не проверяет, и от ситуации, когда вы написали много, а часть осталась без тестов. Маленькие шаги Red-Green держат код и тесты всегда в синхроне.

TDD как способ проектирования

Главная ценность TDD — не столько тесты, сколько то, что вы сначала думаете о поведении, а уже потом о реализации. Записывая тест, вы фактически проектируете интерфейс функции: как её зовут, что принимает, что возвращает, как ведёт себя на краях. Часто на этом этапе обнаруживаешь, что исходная идея неудобна в использовании, — и правишь дизайн ещё до написания кода. Получается, тесты улучшают не только надёжность, но и сам API. А накопленный набор тестов потом бесплатно защищает от регрессий.

Итог

  • TDD: сначала падающий тест (Red), потом минимальный код (Green), потом чистка (Refactor).
  • Тест-первый заставляет думать о поведении и гарантирует покрытие.
  • Увиденное «красное» подтверждает, что тест действительно что-то проверяет.
  • Зелёные тесты делают рефакторинг безопасным.
Проверьте себя
1. Каков порядок шагов в TDD?
AGreen-Red-Refactor
BRed (падающий тест) → Green (минимальный код) → Refactor (улучшение)
CRefactor-Red-Green
DСначала код, потом тест
2. Зачем сначала видеть «красный» (падающий) тест?
AЧтобы замедлить работу
BЧтобы убедиться, что тест действительно что-то проверяет, а не проходит случайно
CЭто необязательно
DЧтобы увеличить покрытие
3. Почему рефакторинг безопасен на шаге Refactor?
AКод нельзя сломать
BЗелёные тесты сразу покраснеют, если поведение изменится
CRefactor запрещает менять код
DТесты выключены
Поддержать проект