Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 19 из 20 Средний 30 мин 130 XP

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
Тест упал с NameError: имя validate_promo в программе не определено. Красная фаза выполнена — поведение зафиксировано, код ещё не написал ни строки.

Падение — это успех красной фазы, а не неудача. Тест пишется первым и поначалу падает: это не помеха, а самый честный способ убедиться, что проверка действительно проверяет. Бывают тесты, которые зелёные с первой секунды, потому что проверяют не то: забытый assert, неверное имя, тест не запускается вовсе. Красная фаза отсеивает таких мертвецов сразу: вот доказательство, что тест умеет падать, — значит, его зелёный цвет будет чего-то стоить.

как выглядит то же падение без --tb=no
>               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
Сокращённая трассировка: pytest показывает сам assert и причину. В работе удобно видеть трассировку целиком, для демонстраций краткий --tb=no достаточно.

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 заново.

Правило рефакторинга: цвет не меняется. Улучшение структуры при красном тесте — самообман: ты не отличишь свежесломанное от давно сломанного. Сломался тест во время рефакторинга — откатывай правку, а не правь два дела сразу: сначала верни зелёный, потом продолжай улучшать. Маленькие шаги с прогоном pytest после каждого — вот вся магия, никакого большего героизма в TDD нет.

Тест как исполняемая спецификация

Посмотри на итоговый test_promo.py глазами человека, который видит проект впервые. Три функции с понятными именами описывают поведение целиком: SALE10 даёт десять процентов, WELCOME5 даёт пять, неизвестный код вежливо возвращает ноль. Документация может устареть, комментарии — наврать, а этот файл исполняется при каждом прогоне и врёт не может: либо поведение такое, либо тест красный.

спецификация validate_promo целиком
# 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
Три имени тестов — три пункта обещания. Новому коллеге не нужно читать promo.py, чтобы понять, как функция себя ведёт: достаточно пробежать этот файл.
спецификация в отчёте: флаг -v разворачивает имена
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
Подробный отчёт печатает пункты спецификации построчно: каждый test_ — один пункт со статусом. Такой вывод можно показывать вместо протокола испытаний.

Это и есть главный дивиденд 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)
Схема: тест воспроизводит баг — правка strip() делает его зелёным. Тест остаётся в сьюте: этот сценарий отныне защищён.
  • короткая функция с ясным контрактом — идеальный кандидат: цикл займёт минуты;
  • баг-фикс — сначала тест, воспроизводящий баг, потом исправление: баг не вернётся;
  • спорная задача, где непонятно, что должно получиться, — написание теста прояснит требования до кода;
  • обмен данными с внешним 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"])
Проверь себя
0 / 5

1. Какой порядок шагов в цикле TDD?

2. Почему красная фаза — это успех, а не провал?

3. Что значит «минимальная реализация» в зелёной фазе?

4. Во время рефакторинга тест покраснел. Что делать?

5. Почему тесты TDD называют исполняемой спецификацией?

Карточки терминов
Запомнено: 0 / 6
Практика

Пройди мини-цикл TDD для функции reverse_word(word), возвращающей слово задом наперёд: сначала напиши test_reverse.py с тестом reverse_word("кот") == "ток" (функции нет) и запусти с --tb=no — зафиксируй красную фазу; затем создай reverse_mod.py с функцией, допиши импорт в тест и запусти снова — зелёная фаза. Напечатай код выхода обоих прогонов.

practice.py
Вопросы и ответы по уроку

Стоит ли применять TDD к каждому участку кода?

TDD даёт максимум там, где поведение ясно и его легко описать тестом: чистые функции, классы с понятным контрактом, баг-фиксы (сначала падающий тест, потом правка). Для одноразовых скриптов и разведочных задач, где само поведение ещё неясно, цикл превращается в формальность — там честнее сначала набросать код, а тесты добавить, когда контур устаканится.

Разве TDD не медленнее обычной разработки?

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

Чем TDD отличается от просто «писать тесты»?

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

Что делать, если тест для новой фичи сразу зелёный?

Подозрительно. Обычно это значит, что тест проверяет не то: неверное имя функции, пропущенный assert, тест не вызывается. Проверь, что тест реально исполняется (добавь намеренную ошибку и убедись, что он падает), и что assert обращается к новой фичи, а не к старому коду.

Понравился урок? Сошлитесь на него

«Тест пишется первым и поначалу падает: это не помеха, а самый честный способ убедиться, что проверка действительно проверяет.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.