Перейти к содержимому
К программе курса
Pytest с нуля: тесты, которые реально работают
8 / 1173%

🔍 Отладка тестов: понимаем ошибки и быстро чиним

35 минут

⚠️ Урок устарел. Свежая версия в курсе pytest-basics: Отладка: понимаем ошибки.

Станьте детективом тестов: находите и исправляйте ошибки за минуты

🕵️‍♂️ До: 30 минут поиска, паника, случайные исправления
После: 2 минуты анализа, уверенность, точные исправления

✅ Читаете traceback как профессионал — находите проблему за 30 секунд
✅ Используете правильные инструменты — pdb, логи, частичные проверки
✅ Знаете частые ошибки новичков — избегаете 80% проблем с самого начала

Реальные ошибки новичков

def test_add_task():
    tasks = []
    result = add_task(tasks, "buy milk")
    assert result == ["buy milk"]  # ✅ Проходит
    # 🐛 Ошибка: забыли проверить исходный список
    assert tasks == ["buy milk"]  # 💥 Падает! add_task возвращает НОВЫЙ список
 
def test_user_age():
    user = User(age=25)
    assert user.age == 25  # ✅ Проходит
    assert user.is_adult is True  # 💥 Падает! is_adult вычисляется, а не хранится
 
def test_api_response():
    response = get("/users/1")
    assert response.status_code == 200  # ✅ Проходит
    assert response.json() == {"id": 1, "name": "Alice"}  # 💥 Падает! Данные другие

Чтение traceback: от страха к пониманию

# ❌ Падает
def test_calculate_total():
    items = [{"price": 10}, {"price": 20}]
>   total = calculate_total(items)
E   TypeError: calculate_total() missing 1 required positional argument: 'tax_rate'
 
tests/test_cart.py:45: TypeError
 
# 🎯 Разбор:
# 1) > строка падения (calculate_total(items))
# 2) E — тип/сообщение ошибки
# 3) tests/test_cart.py:45 — где упало
# 4) Причина: забыли передать tax_rate
 
# ✅ Исправление:
def test_calculate_total():
    items = [{"price": 10}, {"price": 20}]
    total = calculate_total(items, tax_rate=0.1)
    assert total == 33.0

Современные инструменты отладки

Быстрая печать

Просто выводим входы/выходы, чтобы понять, что реально происходит, без захода в отладчик.

def test_complex_calculation():
    data = load_test_data()
    print(f"📊 Input: {data}")
    result = complex_algorithm(data)
    print(f"🎯 Result: {result}")
    assert result == expected_value

pdb — встроенный пошаговый отладчик

Позволяет остановить выполнение, пройтись по коду, посмотреть переменные, не выходя из терминала.

def test_debug_with_pdb():
    user = create_user("test")
    import pdb; pdb.set_trace()  # 🛑 Остановка здесь
 
    # В консоли:
    # n/next — выполнить строку и перейти дальше
    # s/step — войти в функцию
    # p user — посмотреть переменную
    # l — показать контекст кода
    # c — продолжить до следующего брейкпоинта
    # q — выйти
 
    result = process_user(user)
    assert result.is_valid

Breakpoints в IDE (визуальный отладчик)

Ставим точку останова в редакторе и шагами смотрим переменные в UI — удобнее, чем pdb, если работаете в IDE.

  • VS Code: поставьте точку, «Debug Test», смотрите переменные.
  • PyCharm: breakpoint → Debug → Step Over/Into.

Логирование и частичные проверки (когда print/pdb мало)

Логи помогают понять последовательность событий, а частичные проверки (только важные поля) упрощают отладку ответов API.

def test_with_logging(caplog):
    with caplog.at_level("DEBUG"):
        result = complex_operation()
    assert "Starting operation" in caplog.text
    assert any("error" in rec.message for rec in caplog.records)
def test_api_response():
    response = get("/users/1")
    assert response.status_code == 200
    data = response.json()
    assert data["id"] == 1
    assert "name" in data
    assert data["email"].endswith("@example.com")

📋 Таблица частых ошибок и решений

ОшибкаСимптомыРешение
ImportErrorcannot import name / ModuleNotFoundErrorPYTHONPATH=. pytest или pip install -e .
FixtureNotFoundfixture 'X' not foundПроверьте имя, conftest.py, перезапустите IDE
Float comparison0.1 + 0.2 != 0.3pytest.approx(0.3)
Async issuesзабыли await, тест завершается до выполнения корутиныдобавьте await и @pytest.mark.asyncio
Mutation bugsисходные данные меняютсяделайте .copy() и проверяйте оригинал
Flaky testsто проходит, то нетуберите общее состояние, изолируйте данные

Частые ошибки новичков и решения

# ❌ Float: прямое сравнение
def test_float_math_bad():
    assert 0.1 + 0.2 == 0.3  # 💥 Падает
# ✅ Решение
def test_float_math_good():
    assert 0.1 + 0.2 == pytest.approx(0.3)
 
# ❌ Async: забыли await
def test_async_function_bad():
    result = async_function()  # 💥 Падает, нет await
    assert result == "expected"
# ✅ Решение
@pytest.mark.asyncio
async def test_async_function_good():
    result = await async_function()
    assert result == "expected"
 
# ❌ Мутация исходных данных
def test_data_processing_bad():
    data = [1, 2, 3]
    process_data(data)
    assert data == [1, 2, 3]  # 💥 Исходный список изменён
# ✅ Решение
def test_data_processing_good():
    data = [1, 2, 3]
    original = data.copy()
    process_data(data)
    assert original == [1, 2, 3]

Практика: станьте детективом

Задача 1: найдите и исправьте ошибку

# Баговый код (для эксперимента)
def calculate_discount(price: float, discount: float) -> float:
    return price * (1 - discount / 5)  # 🐛 Ошибка в формуле
 
def test_calculate_discount():
    price = 100
    discount = 20
    print(f"💰 Price: {price}, Discount: {discount}")
    result = calculate_discount(price, discount)
    print(f"🎯 Result: {result}")
    # Подсказка: 96.0 вместо 80.0 — изучите формулу скидки
    assert result == 96.0  # после анализа

Задача 2: отладьте с pdb

# Баговый пайплайн
def process_step_1(data):
    return data.get("items", [])  # может вернуть пустой список
 
def process_step_2(items):
    return [item * 2 for item in items]  # падение, если items не числа
 
def process_step_3(items):
    if not items:
        raise ValueError("No items to process")
    return {"status": "completed", "items": items}
 
# Тест для отладки
def test_complex_workflow():
    data = load_test_data()
    print(f"📦 Loaded data: {data}")
    import pdb; pdb.set_trace()
    # p data, n/step через шаги, p result на каждом шаге
    result = process_step_1(data)
    result = process_step_2(result)
    result = process_step_3(result)
    assert result.status == "completed"

Решения (чтобы себя проверить)

# Исправление скидки
def calculate_discount(price: float, discount: float) -> float:
    return price * (1 - discount / 100)
# Подсказка по pdb для workflow:
# В pdb: p data, n (или s) пошагово, p result после каждого шага.
# Баг: смешанные типы или пустые items в данных.
# Починка: фильтруйте/валидируйте входные данные или обработайте пустой список перед step_3.

🚀 Шпаргалка: отладка за 60 секунд

  1. Прочитайте traceback — строка с > + сообщение.
  2. Добавьте печать/лог — посмотрите входы/выходы.
  3. Проверьте входные данные.
  4. Разбейте тест на шаги.
  5. Исправьте причину, не симптом.

Полезные флаги pytest для отладки

pytest --pdb          # 🛑 PDB при падении
pytest --lf           # 🔄 Последние упавшие
pytest -x             # ⏹️ Стоп после первого падения
pytest -v             # 📊 Подробный вывод
pytest -s             # 🖨️ Показать print
pytest --tb=short     # 📄 Короткий traceback
pytest --durations=10 # ⏱️ Топ-10 медленных тестов
pytest test_file.py::test_name --pdb  # 🎯 PDB только для одного теста

📊 Coverage: видим дыры в тестах

Проблема: Вы написали тесты, но не знаете, какие части кода они проверяют. Может быть критичная логика вообще не покрыта?

Решение: Coverage (покрытие кода тестами) — метрика, которая показывает, какие строки кода выполнялись во время тестов.

Установка и базовое использование

# Установка
pip install pytest-cov
 
# Запуск с coverage
pytest --cov=src
 
# С отчётом, какие строки не покрыты
pytest --cov=src --cov-report=term-missing
 
# HTML отчёт (красиво)
pytest --cov=src --cov-report=html
open htmlcov/index.html
 
# Fail если coverage < 70%
pytest --cov=src --cov-fail-under=70

Интерпретация метрик

---------- coverage: platform darwin, python 3.11.0 -----------
Name                     Stmts   Miss  Cover   Missing
------------------------------------------------------
src/__init__.py              0      0   100%
src/app.py                  45      5    89%   23-27
src/cache.py                20      8    60%   15-22
src/utils.py                10      0   100%
------------------------------------------------------
TOTAL                       75     13    83%

Что это значит:

  • Stmts — количество строк кода
  • Miss — строки, которые не выполнялись в тестах
  • Cover — процент покрытия
  • Missing — номера строк, которые не покрыты

Интеграция с IDE

VS Code:

  1. Установите расширение Coverage Gutters
  2. Запустите pytest --cov=src --cov-report=xml
  3. Нажмите Watch в статус-баре
  4. Зелёные линии = покрыты, красные = не покрыты

PyCharm:

  • Встроенная поддержка: Run with Coverage
  • Зелёные/красные полосы на полях редактора

Что считать хорошим покрытием?

# ✅ 70%+ coverage — индустриальный стандарт
# ✅ 80%+ coverage — отличный результат
# ⚠️ 100% coverage — может быть антипаттерном
 
# Почему 100% — антипаттерн?
def format_name(first, last):
    if not first or not last:
        raise ValueError("Names required")
    return f"{first} {last}".title()
 
# ❌ Тест для 100% coverage (бесполезный)
def test_format_name_100_percent():
    assert format_name("john", "doe") == "John Doe"
    with pytest.raises(ValueError):
        format_name("", "")
    with pytest.raises(ValueError):
        format_name("john", "")
    # Погоня за 100% вместо проверки реальных сценариев
 
# ✅ Тест для важной логики
def test_format_name_business_logic():
    assert format_name("john", "doe") == "John Doe"
    with pytest.raises(ValueError):
        format_name("", "doe")

Coverage в CI/CD

Добавьте в ваш CI пайплайн:

# .github/workflows/test.yml
- name: Run tests with coverage
  run: |
    pytest --cov=src --cov-report=xml --cov-fail-under=70
 
- name: Upload coverage to Codecov
  uses: codecov/codecov-action@v3

Практика: найдите непокрытый код

# Код с дырой в покрытии
def process_payment(amount, card):
    if amount <= 0:
        raise ValueError("Invalid amount")
 
    if card.is_expired():  # 🚨 Эта ветка не покрыта
        raise ValueError("Card expired")
 
    return charge_card(card, amount)
 
# Текущий тест
def test_process_payment():
    card = Card(number="1234", expires="12/25")
    result = process_payment(100, card)
    assert result.success is True
 
# Запустите coverage и увидите, что строки с is_expired не покрыты
# Добавьте тест для истёкшей карты
def test_process_payment_expired_card():
    card = Card(number="1234", expires="01/20")
    with pytest.raises(ValueError, match="expired"):
        process_payment(100, card)

Частые вопросы

Вопрос 1: Нужно ли покрывать тестами сеттеры/геттеры? Ответ: Нет. Фокусируйтесь на бизнес-логике, а не на тривиальном коде.

Вопрос 2: Как покрыть код, который зависит от внешних сервисов? Ответ: Используйте моки (урок "Мокинг"). Coverage считает выполненными замоканные пути.

Вопрос 3: Coverage 95%, но баги всё равно есть. Почему? Ответ: Coverage показывает, что код выполнялся, но не что он правильно работает. Важны качественные assert'ы.

Чеклист успешной отладки

  • Прочитал traceback — понимаю где и почему упало
  • Проверил входные/выходные данные (print/log)
  • Использовал подходящий инструмент (print/pdb/IDE)
  • Проверил частые ошибки (float, async, мутации, импорты)
  • Изолировал проблему — разбил сложный тест на шаги
  • Исправил причину, а не просто ожидание
  • Проверил coverage — нет дыр в критичной логике

Поздравляю! Теперь отладка — системный навык, а не паника. И вы видите, что реально покрыто тестами. 🔧

🔍 Отладка тестов: понимаем ошибки и быстро чиним — Pytest с нуля: тесты, которые реально работают — Potapov.me