Урок по треку Python 7 мин

Тестирование на Python: pytest, фикстуры и моки

Фикстуры, parametrize, моки и monkeypatch, изоляция тестов и покрытие.

На собесах всё чаще просят не только написать функцию, но и показать, как вы её протестируете. Де-факто стандарт в Python-проектах это pytest.

Простой тест

# test_price.py
from shop.price import apply_discount

def test_discount_reduces_price():
    assert apply_discount(1000, percent=10) == 900

pytest находит файлы test_*.py и функции test_*, а обычный assert при падении показывает подробную разницу значений (pytest переписывает assert при импорте тестов). Исключения проверяют так:

import pytest

def test_negative_percent():
    with pytest.raises(ValueError, match='percent'):
        apply_discount(1000, percent=-5)

Параметризация

@pytest.mark.parametrize('price, percent, expected', [
    (1000, 0, 1000),
    (1000, 100, 0),
    (999, 33, 669),
])
def test_discount(price, percent, expected):
    assert apply_discount(price, percent) == expected

Один тест, много случаев, и каждый случай отображается отдельно. Краевые значения в таблице видны сразу.

Фикстуры

Фикстура готовит окружение и отдаёт его тесту по имени аргумента:

@pytest.fixture
def user(db):
    u = db.create_user(email='a@b.ru')
    yield u              # всё после yield выполнится как очистка
    db.delete(u)

def test_login(user, client):
    assert client.login(user.email, 'secret').ok
  • scope: function (по умолчанию), class, module, session. Дорогие ресурсы (контейнер БД) создают на сессию, а данные на каждый тест.
  • conftest.py хранит общие фикстуры для каталога.
  • Встроенные: tmp_path (временный каталог), monkeypatch, capsys, caplog.

Моки и подмены

from unittest.mock import Mock, patch

def test_sends_email(monkeypatch):
    sent = []
    monkeypatch.setattr('shop.notify.send_email', lambda to, text: sent.append(to))
    place_order(user_email='a@b.ru')
    assert sent == ['a@b.ru']

Главное правило patch: подменяют имя там, где его ищут, а не там, где объявили. Если shop/orders.py сделал from shop.notify import send_email, патчить надо shop.orders.send_email.

Моки удобны для внешних границ: HTTP, платёжный шлюз, почта, время. Мокать собственную логику и проверять, какие внутренние методы вызвались, плохая идея: тесты ломаются при каждом рефакторинге. Лучше проверять результат и состояние.

Пирамида и изоляция

  • Юнит-тесты: быстрые, без сети и БД, на чистую логику.
  • Интеграционные: с настоящей БД (часто через testcontainers или отдельный контейнер), транзакция на тест с откатом.
  • E2E: немного, на ключевые сценарии.

Тесты не должны зависеть друг от друга и от порядка запуска. Время, случайность и внешние сервисы фиксируют: подменяют часы, задают seed, мокают HTTP.

Покрытие и полезные плагины

pytest-cov (--cov=app --cov-report=term-missing) показывает непокрытые строки. Высокое покрытие не гарантирует качество, но низкое честно показывает дыры. Ещё полезны pytest-xdist (параллельный запуск), pytest-asyncio или anyio (асинхронные тесты), freezegun или time-machine (время), hypothesis (тесты на свойствах).

Частые ошибки

  • Патч не того пути импорта.
  • Тест проверяет реализацию (порядок вызовов), а не поведение.
  • Общие изменяемые данные между тестами, зависимость от порядка.
  • Тесты ходят в настоящую сеть.
  • assert без проверки, что исключение действительно было (вместо pytest.raises).

Что спросят на собесе

  • Как вы тестируете функцию или эндпоинт, что такое фикстура и зачем scope.
  • Как замокать внешний сервис, где делать patch.
  • Что такое parametrize и как вы выбираете тестовые случаи.
  • Чем юнит-тест отличается от интеграционного, как тестировать с БД.
  • Что такое покрытие и достаточно ли его.

Закрепите за 5 минут

После урока в тренажёре 3-5 проверочных вопросов, дальше модуль уходит в повторения.