🔍 Отладка тестов: понимаем ошибки и быстро чиним
⚠️ Урок устарел. Свежая версия в курсе 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_valuepdb — встроенный пошаговый отладчик
Позволяет остановить выполнение, пройтись по коду, посмотреть переменные, не выходя из терминала.
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_validBreakpoints в 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")📋 Таблица частых ошибок и решений
| Ошибка | Симптомы | Решение |
|---|---|---|
| ImportError | cannot import name / ModuleNotFoundError | PYTHONPATH=. pytest или pip install -e . |
| FixtureNotFound | fixture 'X' not found | Проверьте имя, conftest.py, перезапустите IDE |
| Float comparison | 0.1 + 0.2 != 0.3 | pytest.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 секунд
- Прочитайте traceback — строка с
>+ сообщение. - Добавьте печать/лог — посмотрите входы/выходы.
- Проверьте входные данные.
- Разбейте тест на шаги.
- Исправьте причину, не симптом.
Полезные флаги 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:
- Установите расширение Coverage Gutters
- Запустите
pytest --cov=src --cov-report=xml - Нажмите
Watchв статус-баре - Зелёные линии = покрыты, красные = не покрыты
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 — нет дыр в критичной логике
Поздравляю! Теперь отладка — системный навык, а не паника. И вы видите, что реально покрыто тестами. 🔧