TDD, покрытие и тестовая пирамида
TDD: Red — Green — Refactor
Разработка через тестирование (Test-Driven Development) — сначала тест, потом код:
- 🔴 Red — пишем тест для следующего маленького шага поведения. Он падает (кода ещё нет).
- 🟢 Green — пишем минимальный код, чтобы тест прошёл. Даже «глупый».
- 🔵 Refactor — улучшаем код и тесты, сохраняя зелёный цвет.
- Повторяем.
Живой пример: калькулятор строк
Задача: add("1,2,3") возвращает сумму чисел в строке.
# Шаг 1. Red: пустая строка → 0
def test_empty():
assert add("") == 0
# Шаг 1. Green: минимальный код
def add(numbers):
return 0
test_empty(); print("шаг 1 ✓")
# Шаг 2. Red: одно число
def test_single():
assert add("5") == 5
# Шаг 2. Green
def add(numbers):
if not numbers:
return 0
return int(numbers)
test_empty(); test_single(); print("шаг 2 ✓")
# Шаг 3. Red: несколько чисел, в том числе с переносами строк
def test_many():
assert add("1,2,3") == 6
assert add("1\n2,3") == 6
# Шаг 3. Green + Refactor: обобщаем
import re
def add(numbers: str) -> int:
if not numbers:
return 0
return sum(int(n) for n in re.split(r"[,\n]", numbers))
# Шаг 4. Red: отрицательные числа запрещены
def test_negative():
try:
add("1,-2,-3")
except ValueError as e:
assert "-2, -3" in str(e)
else:
raise AssertionError("ожидали ValueError")
# Шаг 4. Green
def add(numbers: str) -> int:
if not numbers:
return 0
values = [int(n) for n in re.split(r"[,\n]", numbers)]
negatives = [str(v) for v in values if v < 0]
if negatives:
raise ValueError(f"отрицательные числа запрещены: {', '.join(negatives)}")
return sum(values)
for test in (test_empty, test_single, test_many, test_negative):
test()
print("все тесты зелёные ✓")
Польза TDD: код сразу тестируемый, тесты не забываются, дизайн получается проще (пишем только нужное), и есть «страховочная сетка» для рефакторинга. На практике многие пишут тесты сразу после кода — главное, чтобы они были.
Тестовая пирамида
| Уровень | Что проверяет | Скорость | Пример инструментов |
|---|---|---|---|
| Модульные (unit) | функцию или класс изолированно | миллисекунды | pytest |
| Интеграционные | взаимодействие с БД, кешем, другим сервисом | секунды | pytest + testcontainers, TestClient |
| End-to-end | систему целиком глазами пользователя | десятки секунд | Playwright, Selenium |
Антипаттерн — «рожок мороженого»: много ручных и E2E-тестов, мало модульных. Такие тесты медленные и ломаются от любой мелочи.
Покрытие кода
coverage.py (или плагин pytest-cov) показывает, какие строки выполнялись во время тестов:
pytest --cov=shop --cov-report=term-missing --cov-branch
Name Stmts Miss Branch BrPart Cover Missing
----------------------------------------------------------------
shop/pricing.py 24 2 10 1 91% 41-42
shop/orders.py 58 0 18 0 100%
----------------------------------------------------------------
TOTAL 82 2 28 1 96%
- Покрытие строк — выполнялась ли строка.
- Покрытие ветвей (
--cov-branch) — прошли ли обе ветки каждогоif. Это строже.
Почему 100% — не гарантия
def average(values):
return sum(values) / len(values)
def test_average():
assert average([2, 4]) == 3 # покрытие 100%...
test_average()
print("покрытие 100%, тест зелёный")
try:
average([]) # ...а ошибка на пустом списке осталась
except ZeroDivisionError as e:
print("ZeroDivisionError:", e)
Покрытие показывает, что не проверено (полезно!), но не доказывает, что проверено хорошо. Разумная цель — 80–90% с осмысленными тестами и обязательное покрытие нового кода. Порог задают в CI: --cov-fail-under=85.
Другие техники
- Тестирование свойств (property-based, библиотека
Hypothesis): описываете свойства («сортировка сохраняет элементы и упорядочивает»), а библиотека генерирует сотни входов и находит минимальный контрпример.
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_sort_properties(xs):
result = my_sort(xs)
assert len(result) == len(xs)
assert all(a <= b for a, b in zip(result, result[1:]))
- Мутационное тестирование (
mutmut): инструмент вносит мелкие ошибки в код (<→<=) и проверяет, упадут ли тесты. Выживший мутант — дыра в тестах. - Snapshot-тесты: сравнение большого вывода (JSON, HTML) с сохранённым эталоном.
- Контрактные тесты между сервисами (Pact).
Тесты в CI
# .github/workflows/tests.yml
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --dev
- run: uv run ruff check .
- run: uv run mypy src
- run: uv run pytest --cov=src --cov-fail-under=85
Pull request не сливают, пока CI не зелёный.
Flaky-тесты
«Мигающий» тест иногда падает без изменений кода. Частые причины и решения:
| Причина | Решение |
|---|---|
| зависимость от текущего времени | передавать время параметром, freezegun |
| случайность | фиксированный seed |
| порядок тестов, общее состояние | изолировать данные, function-фикстуры, pytest-randomly для поиска |
| реальная сеть | моки HTTP |
sleep и гонки в асинхронном коде |
ждать событие, а не время |
| порядок элементов множества/словаря из БД | сортировать перед сравнением |
Flaky-тест чинят сразу: иначе команда привыкает игнорировать красный CI.
Вопрос на собеседовании
«Что такое тестовая пирамида?» — Модель соотношения тестов: в основании много быстрых модульных тестов, выше — меньше интеграционных, на вершине — немного E2E. Чем выше уровень, тем тесты медленнее, дороже и хрупче, но ближе к реальному использованию.
Итог
- TDD: Red → Green → Refactor маленькими шагами.
- Пирамида: много модульных, меньше интеграционных, немного E2E.
- Покрытие (
pytest --cov --cov-branch) показывает непроверенный код, но 100% не гарантирует отсутствия ошибок. - Hypothesis, мутационное тестирование, тесты в CI; flaky-тесты чинят сразу.
Практика решено 0 из 5
Попрактикуйте TDD. Тесты уже написаны (их видно в списке проверок) — реализуйте функцию
fizzbuzz(n), чтобы они прошли: вернуть "Fizz" для чисел, кратных 3, "Buzz" — кратных 5,
"FizzBuzz" — кратных 15, иначе число строкой. Для n <= 0 — ValueError.
Какие тесты проверяют код (5)
assert fizzbuzz(1) == "1"
assert fizzbuzz(3) == "Fizz" and fizzbuzz(9) == "Fizz"
assert fizzbuzz(5) == "Buzz" and fizzbuzz(10) == "Buzz"
assert fizzbuzz(15) == "FizzBuzz" and fizzbuzz(45) == "FizzBuzz"
try:
fizzbuzz(0)
ok = False
except ValueError:
ok = True
assert ok, "fizzbuzz(0) должна бросать ValueError"
def fizzbuzz(n):
if n <= 0:
raise ValueError("n должно быть положительным")
if n % 15 == 0:
return "FizzBuzz"
if n % 3 == 0:
return "Fizz"
if n % 5 == 0:
return "Buzz"
return str(n)
Тестирование свойств: вместо конкретных примеров проверьте свойства функции сортировки
на сотне случайных списков. Напишите check_sort_properties(sort_func), которая для 100 случайных
списков (используйте random.Random(42)) проверяет через assert:
1) длина результата равна длине входа; 2) результат упорядочен; 3) это та же мультимножество
элементов (Counter совпадает). Проверка подставит правильную и сломанные сортировки.
Какие тесты проверяют код (2)
check_sort_properties(sorted)
def fails(func):
try:
check_sort_properties(func)
return False
except AssertionError:
return True
assert fails(lambda xs: sorted(set(xs))), "Не поймана сортировка, теряющая дубликаты"
assert fails(lambda xs: sorted(xs)[:-1] if len(xs) > 5 else sorted(xs)), "Не поймана сортировка, теряющая элементы"
assert fails(lambda xs: xs if len(xs) % 7 == 3 else sorted(xs)), "Не поймана сортировка, которая иногда не сортирует"
assert fails(lambda xs: sorted(x if x != 7 else 8 for x in xs)), "Не поймана сортировка, подменяющая элементы"
import random
from collections import Counter
def check_sort_properties(sort_func):
rnd = random.Random(42)
for _ in range(100):
data = [rnd.randint(-50, 50) for _ in range(rnd.randint(0, 30))]
result = sort_func(list(data))
assert len(result) == len(data)
assert all(a <= b for a, b in zip(result, result[1:]))
assert Counter(result) == Counter(data)
В каком порядке идут шаги TDD?
Покрытие кода тестами 100%. Что это гарантирует?
К какому уровню тестовой пирамиды относится тест?
| модульный | интеграционный | end-to-end | |
|---|---|---|---|
| Функция расчёта скидки на разных входных данных | |||
| Репозиторий сохраняет заказ в настоящую тестовую PostgreSQL и читает обратно | |||
| Браузер (Playwright) логинится, кладёт товар в корзину и оформляет заказ | |||
| POST /orders через тестовый клиент FastAPI с тестовой базой | |||
| Метод валидации класса Email без внешних зависимостей |
Войдите, чтобы отмечать пройденные уроки.