Подписывайтесь:

Блог AST-SoftPro

Тестирование Python-приложений: pytest, fixtures и mocking

14.06.2026 5 мин чтения

Введение

Тестирование 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-инструменты, внутренние платформы и автоматизация. Хороший набор тестов не заменяет архитектуру, но делает изменения безопаснее и помогает команде двигаться быстрее без страха случайно сломать уже работающую часть системы.

AI-Помощник