Блог AST-SoftPro
Тестирование Python-приложений: pytest, fixtures и mocking
Введение
Тестирование Python-приложений давно перестало быть «дополнительной опцией». В реальных проектах код постоянно меняется: появляются новые интеграции, уточняются бизнес-правила, рефакторятся сервисные слои, а API начинает обслуживать больше клиентов. Без автоматических тестов такие изменения быстро превращаются в ручную проверку «на глаз», а цена ошибки растёт с каждым релизом.
В этой статье разберём практический подход к тестированию Python-приложений с помощью pytest, fixtures и mocking. Это не обзор синтаксиса ради синтаксиса, а набор приёмов, которые помогают писать стабильные, понятные и поддерживаемые тесты для backend-сервисов, API, интеграций и бизнес-логики.
В командах AST-SOFT мы используем похожий подход при разработке внутренних инструментов, интеграционных сервисов и AI-решений: тесты должны быстро подсказывать, что сломалось, а не превращаться в ещё один источник неопределённости.
Почему pytest
pytest стал фактическим стандартом для Python-проектов не только из-за простого синтаксиса. Его сила — в сочетании минимального порога входа и мощных возможностей для масштабирования.
Преимущества pytest:
- тесты пишутся как обычные функции, без обязательного наследования от базовых классов;
- assertions выглядят естественно:
assert result == expected; - fixtures позволяют выносить подготовку данных и зависимости;
- параметризация помогает запускать один тест на разных наборах входных данных;
- плагины покрывают покрытие, async-тесты, параллельный запуск, отчёты и интеграции с CI/CD.
Минимальный пример:
def add(a, b):
return a + b
def test_add_positive_numbers():
assert add(2, 3) == 5
Запуск:
pytest
Если тесты лежат в файлах вида test_*.py или *_test.py, а функции начинаются с test_, pytest найдёт их автоматически.
Структура тестов в проекте
Для небольшого сервиса структура может быть такой:
project/
app/
services/
orders.py
repositories/
orders_repository.py
external/
payment_client.py
tests/
test_orders_service.py
fixtures/
orders.py
Главный принцип: тесты должны проверять поведение, а не копировать внутреннюю реализацию. Хороший тест отвечает на вопрос: «Что должен сделать объект в конкретной ситуации?» Плохой тест привязан к случайным деталям реализации и ломается при любом рефакторинге.
Fixtures: подготовка состояния
Fixture — это функция, которая готовит данные, зависимости или окружение для теста. pytest вызывает fixture, если тест запрашивает её по имени.
import pytest
@pytest.fixture
def sample_order():
return {
"id": 1,
"customer_id": 42,
"items": [
{"sku": "book", "qty": 2, "price": 500},
{"sku": "pen", "qty": 1, "price": 50},
],
}
def test_order_total(sample_order):
total = sum(item["qty"] * item["price"] for item in sample_order["items"])
assert total == 1050
Такой тест читается почти как документация: есть набор данных и ожидаемый результат.
Scope fixtures
По умолчанию fixture создаётся заново для каждого теста. Иногда это избыточно, например если нужно один раз открыть подключение к тестовой базе.
@pytest.fixture(scope="session")
def database_url():
return "sqlite:///test.db"
Основные уровни scope:
function— новый объект для каждого теста;class— один объект для класса тестов;module— один объект для модуля;package— один объект для пакета;session— один объект на весь запуск тестов.
Не стоит делать всё session, если тесты меняют состояние объекта. Для изоляции чаще безопаснее использовать function или module.
Параметризация тестов
Параметризация заменяет несколько похожих тестов одним выразительным сценарием.
@pytest.mark.parametrize(
"items, expected",
[
([], 0),
([{"qty": 1, "price": 100}], 100),
([{"qty": 2, "price": 300}, {"qty": 1, "price": 50}], 650),
],
)
def test_calculate_total(items, expected):
total = sum(item["qty"] * item["price"] for item in items)
assert total == expected
Это особенно полезно для граничных случаев: пустые списки, отрицательные значения, некорректные данные, ошибки внешних сервисов.
Mocking: когда не нужно вызывать реальные зависимости
Mocking нужен, когда тест не должен зависеть от сети, базы данных, файловой системы, сторонних API или случайных событий. Цель — проверить поведение конкретного модуля, изолировав внешние взаимодействия.
В Python стандартный инструмент — unittest.mock.
from unittest.mock import Mock
def test_send_confirmation_uses_email_service():
email_service = Mock()
email_service.send.return_value = True
result = email_service.send("customer@example.com", "Заказ создан")
assert result is True
email_service.send.assert_called_once_with(
"customer@example.com",
"Заказ создан",
)
Mock позволяет проверить, что метод был вызван, с какими аргументами и сколько раз. Это полезно для интеграций, где реальное отправление письма или платежа нежелательно во время тестов.
Patch: временная замена зависимости
patch временно подменяет объект в указанном месте импорта.
from unittest.mock import patch
def get_user_name(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status()
return response.json()["name"]
def test_get_user_name_uses_api_response():
fake_response = Mock()
fake_response.json.return_value = {"name": "Анна"}
fake_response.raise_for_status.return_value = None
with patch("orders_service.requests.get", return_value=fake_response) as mocked_get:
name = get_user_name(42)
assert name == "Анна"
mocked_get.assert_called_once_with("https://api.example.com/users/42")
Важно патчить не исходную библиотеку, а то место, где она используется вашим кодом. Если модуль импортировал requests, патчить нужно orders_service.requests.get, а не просто requests.get.
Практический пример: сервис заказов
Допустим, есть сервис, который создаёт заказ и отправляет уведомление.
class OrderService:
def __init__(self, repository, notifier):
self.repository = repository
self.notifier = notifier
def create_order(self, customer_id, items):
order = {"customer_id": customer_id, "items": items, "status": "created"}
saved_order = self.repository.save(order)
self.notifier.notify(customer_id, saved_order["id"])
return saved_order
Тестируем поведение без реальной базы и реального нотификатора:
def test_create_order_saves_and_notifies():
repository = Mock()
repository.save.return_value = {"id": 10, "customer_id": 42, "status": "created"}
notifier = Mock()
service = OrderService(repository, notifier)
order = service.create_order(42, [{"sku": "book", "qty": 1}])
assert order["id"] == 10
repository.save.assert_called_once()
notifier.notify.assert_called_once_with(42, 10)
Такой тест быстро падает, если сервис перестаёт сохранять заказ или забывает уведомить клиента. При этом он не требует подключения к реальной БД.
Тестирование ошибок и граничных условий
Хорошие тесты проверяют не только happy path. Нужно покрывать ситуации, где система должна корректно реагировать на ошибку.
import pytest
def test_create_order_rejects_empty_items():
service = OrderService(Mock(), Mock())
with pytest.raises(ValueError, match="items"):
service.create_order(42, [])
Полезные сценарии:
- пустые входные данные;
- отрицательные количества;
- отсутствие прав доступа;
- недоступность внешней API;
- таймаут;
- некорректный ответ третьей стороны;
- повторная отправка одного и того же запроса.
Async-тесты
Для FastAPI, aiohttp и async-сервисов часто нужны async-тесты. В современном стеке удобно использовать pytest-asyncio.
import pytest
@pytest.mark.asyncio
async def test_fetch_profile():
client = Mock()
client.get.return_value.json.return_value = {"id": 1, "name": "Ivan"}
profile = await fetch_profile(client, 1)
assert profile["name"] == "Ivan"
Для FastAPI-проектов также часто используют httpx.AsyncClient вместе с ASGITransport, чтобы проверять реальные HTTP-сценарии без запуска отдельного сервера.
Что не стоит мокать
Mocking — полезный инструмент, но не универсальный. Не стоит заменять моками то, что уже является частью проверяемой логики. Например, если тест должен проверить расчёт стоимости заказа, не нужно мокать сам расчёт.
Лучше мокать:
- внешние API;
- отправку email/SMS;
- работу с очередями;
- медленные или недетерминированные операции;
- платёжные провайдеры;
- файловые операции, если они не являются целью теста.
Не нужно мокать:
- чистые функции;
- простую бизнес-логику;
- код, поведение которого и проверяется.
Чек-лист качественных тестов
Перед добавлением теста полезно задать несколько вопросов:
- тест проверяет одно поведение или сразу пять?
- имя теста понятно без чтения тела?
- подготовка данных не скрыта в десяти строкках непонятных setup-вызовов?
- внешние зависимости изолированы?
- тест детерминирован и не зависит от текущего времени без явной передачи времени?
- assertion сообщает, что именно ожидалось?
- тест ломается только при реальной ошибке, а не при безобидном рефакторинге?
Если тест сложно читать, его будет сложно поддерживать. В долгосрочной перспективе понятность важнее «короткости любой ценой».
Заключение
pytest, fixtures и mocking вместе дают практическую основу для надёжного тестирования Python-приложений. pytest отвечает за запуск и выразительные assertions, fixtures — за повторяемую подготовку состояния, а mocking — за изоляцию от внешних зависимостей.
В проектах AST-SOFT мы применяем эти подходы там, где важны стабильность, предсказуемость и быстрая обратная связь: backend-сервисы, интеграции, AI-инструменты, внутренние платформы и автоматизация. Хороший набор тестов не заменяет архитектуру, но делает изменения безопаснее и помогает команде двигаться быстрее без страха случайно сломать уже работающую часть системы.