TDD, покрытие и тестовая пирамида

⏱ 35 мин · 🧩 заданий: 5

TDD: Red — Green — Refactor

Разработка через тестирование (Test-Driven Development) — сначала тест, потом код:

  1. 🔴 Red — пишем тест для следующего маленького шага поведения. Он падает (кода ещё нет).
  2. 🟢 Green — пишем минимальный код, чтобы тест прошёл. Даже «глупый».
  3. 🔵 Refactor — улучшаем код и тесты, сохраняя зелёный цвет.
  4. Повторяем.

Живой пример: калькулятор строк

Задача: 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: код сразу тестируемый, тесты не забываются, дизайн получается проще (пишем только нужное), и есть «страховочная сетка» для рефакторинга. На практике многие пишут тесты сразу после кода — главное, чтобы они были.

Тестовая пирамида

/\ /E2E\ мало: медленные, хрупкие, дорогие — критичные сценарии /------\ /интегра-\ средне: код + БД / очередь / HTTP-клиент / ционные \ /------------\ / модульные \ много: быстрые (мс), изолированные — вся логика /________________\
Уровень Что проверяет Скорость Пример инструментов
Модульные (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

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Программа на Python

Попрактикуйте 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"

Задание 2 Программа на Python

Тестирование свойств: вместо конкретных примеров проверьте свойства функции сортировки на сотне случайных списков. Напишите 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)), "Не поймана сортировка, подменяющая элементы"

Задание 3 Один вариант ответа

В каком порядке идут шаги TDD?

Задание 4 Один вариант ответа

Покрытие кода тестами 100%. Что это гарантирует?

Задание 5 Заполнить таблицу

К какому уровню тестовой пирамиды относится тест?

модульныйинтеграционныйend-to-end
Функция расчёта скидки на разных входных данных
Репозиторий сохраняет заказ в настоящую тестовую PostgreSQL и читает обратно
Браузер (Playwright) логинится, кладёт товар в корзину и оформляет заказ
POST /orders через тестовый клиент FastAPI с тестовой базой
Метод валидации класса Email без внешних зависимостей
Отметьте ячейку — «+», пустая — «−».

Войдите, чтобы отмечать пройденные уроки.