TDD: сначала тест, потом код
Промокод, которого ещё не существует: пишем тест на несуществующую функцию, любуемся красным, реализуем минимум — и доводим код до чистоты, не теряя зелёного цвета.
Редакция Питоники
До сих пор тесты появлялись после кода: вот функция — давайте её проверим. TDD (test-driven development, разработка через тестирование) меняет порядок местами: сначала тест, потом код. Звучит как перестановка ради перестановки, но на деле это меняет всё: ты начинаешь с вопроса «как функция должна себя вести?» вместо «что я уже написал?» — и у каждой строчки рабочего кода появляется проверка, которая её дождалась.
К середине курса у тебя есть всё, чтобы тесты писать быстро: фикстуры готовят данные, pytest.raises проверяет ошибки, tmp_path держит файлы в порядке. Остался последний барьер — психологический: тесты воспринимаются как налог на код, который и так работает. TDD ломает именно это восприятие: тест — не налог, а чертёж, по которому код строится. И строится быстрее, чем без него, потому что отладка выкидывается из цикла целиком.
Пройдём полный цикл на живом примере: в магазин добавляют промокоды, и нужна функция validate_promo(code), возвращающая скидку в процентах. Функции ещё не существует — есть только желание, чтобы она делала. Идеальная стартовая точка для TDD: требования крошечные, контракт прозрачный, а каждый шаг цикла виден в отчёте как на ладони.
Цикл red-green-refactor
TDD — это не «сначала все тесты, потом весь код», а короткий цикл из трёх шагов, который прогоняется на каждый пункт поведения. Весь оборот занимает минуты. И это не церемония ради процесса: цикл гарантирует, что ни одна строчка рабочего кода не появится без проверки, которая её требует, — ни сегодня, ни через полгода, когда код будут править чужие руки.
| Фаза | Что делаем | Какой цвет на выходе |
|---|---|---|
| красный | пишем тест на поведение, которого ещё нет в коде | тест падает — функции не существует |
| зелёный | реализуем функцию минимально, только чтобы тест прошёл | тест проходит |
| рефакторинг | улучшаем код, поведение не трогаем | тест всё ещё проходит |
Цвета — из традиции: красным в отчёте горит упавший тест, зелёным — прошедший. Смысл цикла в том, что ты никогда не находишься далеко от зелёного: шаги маленькие, и каждый заканчивается прогоном pytest. Разберём фазы по очереди на validate_promo.
Красный: тест на несуществующую функцию
Пишем первый тест так, как хотели бы пользоваться функцией: передали код промокода — получили скидку в процентах. Функции validate_promo не существует, и это не проблема, а замысел.
from pathlib import Path
import sys
Path("test_promo.py").write_text('''def test_basic_promo():
assert validate_promo("SALE10") == 10
''', encoding="utf-8")
sys.modules.pop("test_promo", None)
import pytest
rc = pytest.main(["test_promo.py", "-q", "--no-header", "--tb=no", "-p", "no:cacheprovider"])
print("exit:", int(rc))
F [100%] =========================== short test summary info =========================== FAILED test_promo.py::test_basic_promo - NameError: name 'validate_promo' is ... 1 failed in 0.02s exit: 1
Падение — это успех красной фазы, а не неудача. Тест пишется первым и поначалу падает: это не помеха, а самый честный способ убедиться, что проверка действительно проверяет. Бывают тесты, которые зелёные с первой секунды, потому что проверяют не то: забытый assert, неверное имя, тест не запускается вовсе. Красная фаза отсеивает таких мертвецов сразу: вот доказательство, что тест умеет падать, — значит, его зелёный цвет будет чего-то стоить.
> assert validate_promo("SALE10") == 10
E NameError: name 'validate_promo' is not defined
test_promo.py:2: NameError
FAILED test_promo.py::test_basic_promo - NameError: name 'validate_promo' is not defined
NameError — самый честный из красных: он говорит, что обещанного поведения в программе нет вообще. По ходу дела красными бывают и другие падения: AssertionError покажет, что функция есть, но ведёт себя не так, как ты ожидал, — тоже полезный сигнал, только уже про расхождение ожиданий с существующим кодом. Разбирать чужой модуль начинают с того, что пишут тест на желаемое поведение и смотрят, как именно он краснеет.
Зелёный: минимальная реализация
Теперь делаем тест зелёным — но ровно минимальными средствами. Не проектируем систему промокодов впрок, не заводим базу кодов с HTTP-проверкой и админкой: тест требует одного — чтобы SALE10 давал 10. Пишем только это, плюс импорт в тест — в красной фазе модуля ещё не существовало.
from pathlib import Path
import sys
Path("promo.py").write_text('''def validate_promo(code):
if code == "SALE10":
return 10
return 0
''', encoding="utf-8")
Path("test_promo.py").write_text('''from promo import validate_promo
def test_basic_promo():
assert validate_promo("SALE10") == 10
''', encoding="utf-8")
for name in ("promo", "test_promo"):
sys.modules.pop(name, None)
import pytest
rc = pytest.main(["test_promo.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
. [100%] 1 passed in 0.02s exit: 0
Минимальность — не лень, а дисциплина. Код, написанный «чтобы тест прошёл», не содержит ни одной строки, которая не нужна прямо сейчас: ни загадочных настроек «на будущее», ни веток для сценариев, о которых никто не просил. Всё это обычно возвращается багами — а здесь будущего кода просто нет, и поддерживать нечего. Если завтра понадобится промокод WELCOME5, следующий цикл добавит и его — тестом.
Рефакторинг: цвет тот же, код лучше
Зелёная реализация работает, но выглядит наивно: цепочка if растянется в простыню, когда промокодов станет пять. Пора улучшить структуру — и вот здесь зелёный тест превращается в страховку: меняешь код, прогоняешь тест, и цвет говорит, сломал ты что-то или нет. Заодно расширим спецификацию: добавим тест на второй промокод и на неизвестный код — новое поведение уже покрыто, потому что реализация в виде словаря покрывает его бесплатно.
from pathlib import Path
import sys
Path("promo.py").write_text('''DISCOUNTS = {
"SALE10": 10,
"WELCOME5": 5,
}
def validate_promo(code):
"""Возвращает скидку промокода в процентах или 0."""
return DISCOUNTS.get(code, 0)
''', encoding="utf-8")
Path("test_promo.py").write_text('''from promo import validate_promo
def test_basic_promo():
assert validate_promo("SALE10") == 10
def test_welcome_promo():
assert validate_promo("WELCOME5") == 5
def test_unknown_promo_gives_zero():
assert validate_promo("HACK200") == 0
''', encoding="utf-8")
for name in ("promo", "test_promo"):
sys.modules.pop(name, None)
import pytest
rc = pytest.main(["test_promo.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
... [100%] 3 passed in 0.02s exit: 0
Правило рефакторинга: цвет не меняется. Улучшение структуры при красном тесте — самообман: ты не отличишь свежесломанное от давно сломанного. Сломался тест во время рефакторинга — откатывай правку, а не правь два дела сразу: сначала верни зелёный, потом продолжай улучшать. Маленькие шаги с прогоном pytest после каждого — вот вся магия, никакого большего героизма в TDD нет.
Тест как исполняемая спецификация
Посмотри на итоговый test_promo.py глазами человека, который видит проект впервые. Три функции с понятными именами описывают поведение целиком: SALE10 даёт десять процентов, WELCOME5 даёт пять, неизвестный код вежливо возвращает ноль. Документация может устареть, комментарии — наврать, а этот файл исполняется при каждом прогоне и врёт не может: либо поведение такое, либо тест красный.
# test_promo.py - читается как договор с функцией
from promo import validate_promo
def test_basic_promo():
assert validate_promo("SALE10") == 10
def test_welcome_promo():
assert validate_promo("WELCOME5") == 5
def test_unknown_promo_gives_zero():
assert validate_promo("HACK200") == 0
pytest test_promo.py -v --no-header
============================= test session starts ============================= collecting ... collected 3 items test_promo.py::test_basic_promo PASSED [ 33%] test_promo.py::test_welcome_promo PASSED [ 66%] test_promo.py::test_unknown_promo_gives_zero PASSED [100%] ============================== 3 passed in 0.03s ============================== exit: 0
Это и есть главный дивиденд TDD: спецификация, которая не расходится с кодом, потому что пишется одновременно с ним и проверяется машиной. Через полгода, когда проект вспомнит про летние распродажи, никто не будет восстанавливать поведение по исходникам — достаточно открыть тест и дописать следующий пункт.
Следующий пункт добавляется тем же циклом, и это стоит увидеть в чистом виде: сначала новый тест — красный, потом одна строка реализации — зелёный. Никакого проектного совещания, никакого переписывания: спецификация растёт построчно.
# Шаг 1 (красный): в test_promo.py добавился тест -
# летняя распродажа удваивает скидку SALE10
def test_summer_doubles_sale10():
assert validate_promo("SUMMER") == 20
# прогон: FAILED - NameError? нет, KeyError? нет -
# validate_promo("SUMMER") вернул 0, тест красный по assert
# Шаг 2 (зелёный): одна строка в promo.py
DISCOUNTS = {
"SALE10": 10,
"WELCOME5": 5,
"SUMMER": 20,
}
Отдельная статья экономии — баг-фиксы. Классическая схема: пользователь нашёл баг, ты воспроизводишь его тестом (красным — он доказывает, что баг существует и теперь зафиксирован), чинишь код до зелёного, и тест остаётся в сьюте навсегда. Тот же баг уже не сможет вернуться незамеченным: регрессия закрывается не словами в чате, а строчкой в отчёте.
# баг: код с пробелами не находится, скидка теряется
def test_promo_with_spaces():
assert validate_promo(" SALE10 ") == 10
# красный: вернулось 0, должно 10 ->
# зелёная правка в promo.py
def validate_promo(code):
return DISCOUNTS.get(code.strip(), 0)
- короткая функция с ясным контрактом — идеальный кандидат: цикл займёт минуты;
- баг-фикс — сначала тест, воспроизводящий баг, потом исправление: баг не вернётся;
- спорная задача, где непонятно, что должно получиться, — написание теста прояснит требования до кода;
- обмен данными с внешним API — тесты-стабы описывают ожидаемые ответы до того, как упадут настоящие;
- одноразовый скрипт на выброс — можно без церемоний: TDD ради TDD никому не нужен.
Отдельно про команду: TDD меняет и разговоры внутри неё. Обсуждение «как нам сделать промокоды?» превращается в обсуждение «каким должен быть тест?» — а это разговор о поведении, понятный и тестировщику, и аналитику. Спорные места всплывают в момент формулировки теста, а не после деплоя. Красно-зелёный ритм — это ещё и язык, на котором разработчики договариваются о том, что вообще должно получиться.
Что дальше
Цикл пройден трижды: красный тест доказал, что проверка живая, минимальная реализация дала зелёный, рефакторинг навёл порядок без потери цвета — и в проекте осталась исполняемая спецификация промокодов. Осталось применить ритм масштабно: финальный проект собирает полный тест-сьют для модуля заказов — conftest, параметризация, raises, monkeypatch и tmp_path в одной папке, с итоговым прогоном из десятка тестов. Если захочешь освежить, как читается красный отчёт, — вернись к уроку про падения.
Красная фаза — доказательство, что тест умеет падать; зелёная — что код достаточно хорош прямо сейчас; рефакторинг — что он станет лучше, не перестав быть хорошим.
Сначала предскажи ответ в голове — это главный навык программиста.
# promo.py ещё не существует
def test_zero_for_empty():
assert validate_promo("") == 0
# pytest.main(["test_promo.py", "-q", "--no-header", "--tb=no"])
# promo.py
def validate_promo(code):
return {"SALE10": 10}.get(code, 0)
# test_promo.py
from promo import validate_promo
def test_sale10():
assert validate_promo("SALE10") == 10
def test_unknown():
assert validate_promo("NOPE") == 0
# pytest.main(["test_promo.py", "-q", "--no-header"])
def add_tax(amount):
return amount + amount * 0.2
def test_add_tax():
assert add_tax(100) == 120
def test_add_tax_zero():
assert add_tax(0) == 0
# pytest.main(["test_tax.py", "-q", "--no-header"])
1. Какой порядок шагов в цикле TDD?
2. Почему красная фаза — это успех, а не провал?
3. Что значит «минимальная реализация» в зелёной фазе?
4. Во время рефакторинга тест покраснел. Что делать?
5. Почему тесты TDD называют исполняемой спецификацией?
Пройди мини-цикл TDD для функции reverse_word(word), возвращающей слово задом наперёд: сначала напиши test_reverse.py с тестом reverse_word("кот") == "ток" (функции нет) и запусти с --tb=no — зафиксируй красную фазу; затем создай reverse_mod.py с функцией, допиши импорт в тест и запусти снова — зелёная фаза. Напечатай код выхода обоих прогонов.
Стоит ли применять TDD к каждому участку кода?
TDD даёт максимум там, где поведение ясно и его легко описать тестом: чистые функции, классы с понятным контрактом, баг-фиксы (сначала падающий тест, потом правка). Для одноразовых скриптов и разведочных задач, где само поведение ещё неясно, цикл превращается в формальность — там честнее сначала набросать код, а тесты добавить, когда контур устаканится.
Разве TDD не медленнее обычной разработки?
На коротком участке — да, тест появляется раньше кода. В масштабе недели TDD обычно выигрывает: баги ловятся в момент рождения, отладка сжимается, а рефакторинг идёт под защитой зелёных тестов. Главную экономию даёт баг-фикс: воспроизводящий тест пишется один раз и сторожит этот баг навсегда.
Чем TDD отличается от просто «писать тесты»?
Порядком и ролью теста. При обычном подходе тесты пишутся после кода и подтверждают то, что уже сделано. В TDD тест первичен: он формулирует требование до реализации, а цвет отчёта ведёт разработку — красный говорит «поведения ещё нет», зелёный «можно остановиться», и рефакторинг разрешён только под зелёным.
Что делать, если тест для новой фичи сразу зелёный?
Подозрительно. Обычно это значит, что тест проверяет не то: неверное имя функции, пропущенный assert, тест не вызывается. Проверь, что тест реально исполняется (добавь намеренную ошибку и убедись, что он падает), и что assert обращается к новой фичи, а не к старому коду.
Понравился урок? Сошлитесь на него
«Тест пишется первым и поначалу падает: это не помеха, а самый честный способ убедиться, что проверка действительно проверяет.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
pytest · Урок 3
Отчёт pytest: подробный вывод -v и разбор падения
Зелёная точка — скучный отчёт, и это хорошо. Настоящая сила pytest раскрывается при падении: он показывает строку, ожидание, реальность и разницу между ними.
pytest · Урок 18
Что тестировать: стратегия, границы и покрытие
Механику pytest ты знаешь — осталось ответить на главный вопрос: какие тесты писать. Поведение вместо реализации, границы диапазонов, ветки ошибок, покрытие и пирамида.
pytest · Урок 20
Финальный проект: полный тест-сьют модуля работы с заказами
Выпускной: модуль заказов со скидками, классом Order, JSON и валютой — и полный сьют из шестнадцати тестов, который держит его весь целиком и гоняется одной командой.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Flask · Урок 15
Тестирование Flask: pytest и test_client
Пятнадцатый урок курса Flask — мост в мир автотестов: проверки API из предыдущих уроков становятся тест-функциями pytest с assert, а app.test_client() работает внутри теста без сервера и сети.
flask тестирование pytest test_clientтестирование flask api pytest
openpyxl · Урок 1
Excel на Python: первая книга xlsx через openpyxl
Первая книга xlsx из кода: Workbook, запись в ячейки и wb.save — настоящий Excel-файл создаётся прямо в браузере, без установленного Excel.
openpyxl для начинающих
pytest · Урок 1
Первый тест на pytest: assert, запуск и первый отчёт
Первая тест-функция на обычном assert: pytest сам находит её, запускает и печатает отчёт — точка, 1 passed и код выхода 0. Всё прямо в браузере.
pytest первый тестpytest assert