Перейти к основному содержимому

Тестирование на pytest

Разработчику

Тестирование на pytest

pytest - автотесты в Python-проекте

Вы изменили функцию "нормализации заголовка" — и через неделю форма на сайте перестала сохранять данные. Вручную проверять десятки сценариев после каждого коммита утомительно.

Автотест — скрипт на Python, который:

  1. вызывает ваш код или HTTP API;
  2. сравнивает результат с ожиданием;
  3. печатает зелёный (всё ок) или красный (что-то сломалось).

Запуск одной командой pytest занимает секунды.

В Python есть встроенный unittest (классы TestCase). На практике чаще берут pytest — меньше шаблонного кода, удобные фикстуры, понятный вывод при падении.

Где применятьПример в энциклопедии
Чистые функцииnormalize_title(), add_task()
HTTP APIFastAPI 3432, Flask 3411
Django / DRFpytest-django, APIClient

Словарь

ТерминПростыми словами
ТестФункция test_*, которая что-то проверяет
assert"Утверждение": если ложь — тест провален
Фикстура (fixture)Подготовка перед тестом (БД, клиент API)
parametrizeОдин тест — много наборов входных данных
TestClient"Виртуальный браузер" к FastAPI без сети
coverageКакой % кода хотя бы раз выполнился в тестах

Что получится

Папка tests/ с идеями:

  • простой assert для функции;
  • таблица примеров через parametrize;
  • запросы к API через TestClient.

Установка и запуск

pip install pytest

Разбор:

  • Команда устанавливает pytest в активное окружение проекта.
  • После установки становится доступна консольная команда pytest.
  • В учебных и рабочих проектах это минимальная база для автопроверок перед коммитом.

Структура проекта:

myapp/
main.py
logic.py
tests/
test_logic.py
test_api.py

Pytest сам находит файлы test_*.py и функции test_* внутри них.

pytest # все тесты
pytest -q # краткий вывод
pytest -v # имя каждого теста
pytest tests/test_logic.py::test_add_positive # один тест

Разбор:

  • pytest запускает все найденные тесты.
  • -q сокращает вывод, -v показывает подробные имена кейсов.
  • Формат path::test_name позволяет точечно запускать один тест.
  • Точечный запуск ускоряет отладку, когда нужно проверить конкретный сценарий.

Запускайте из корня проекта, где видны и logic.py, и tests/. Иначе from logic import ... может дать ModuleNotFoundError.


Первый тест — чистая функция

Код приложения logic.py:

def normalize_title(title: str) -> str:
return title.strip()

Тест tests/test_logic.py:

from logic import normalize_title


def test_normalize_strips_spaces():
assert normalize_title(" hello ") == "hello"


def test_normalize_empty_becomes_empty():
assert normalize_title(" ") == ""

Разбор assert:

assert normalize_title(" hello ") == "hello"

Читается: "я утверждаю, что выражение слева равно выражению справа". Если нет — pytest покажет оба значения:

AssertionError: assert ' hello' == 'hello'

В unittest писали бы self.assertEqual(a, b) внутри класса. В pytest достаточно обычного assert.


parametrize — одна функция, много кейсов

Вместо копирования трёх функций — таблица входов и ожиданий:

Код ITЗагрузка примера кода…

ЭлементСмысл
@pytest.mark.parametrizeДекоратор pytest
"raw, expected"Имена аргументов теста
список кортежейТри прогона = три строки в отчёте
raw, expectedПодставляются в функцию автоматически

Добавить граничный случай = одна строка в списке, а не новый файл.


Фикстуры — общая подготовка

Если десяти тестам нужен один и тот же "список заметок", копировать создание списка неудобно. Выносите в fixture:


import pytest


@pytest.fixture
def sample_notes():
return [{"id": 1, "text": "learn pytest"}]


def test_list_has_one_item(sample_notes):
assert len(sample_notes) == 1
assert sample_notes[0]["text"] == "learn pytest"

Разбор:

  • @pytest.fixture объявляет функцию-подготовку тестовых данных.
  • sample_notes автоматически подставляется в аргументы теста по имени.
  • Проверки assert ... фиксируют ожидаемую структуру и содержимое данных.
  • Такой подход убирает дублирование setup-кода между тестами.
  • Фикстуры помогают держать тесты короче и понятнее при росте проекта.

Как это работает:

  1. Pytest видит аргумент sample_notes в тесте.
  2. Ищет фикстуру с таким именем.
  3. Вызывает sample_notes() перед тестом.
  4. Передаёт результат в тест.

Фикстура с yield — и teardown

@pytest.fixture
def temp_file(tmp_path):
p = tmp_path / "data.txt"
p.write_text("hello", encoding="utf-8")
yield p
# после теста файл в tmp_path удалится автоматически

Разбор:

  • tmp_path создаёт временную папку, уникальную для теста.
  • Код до yield выполняет setup (подготовку файла).
  • Код после yield — teardown, где выполняют очистку ресурсов.
  • Это снижает риск "грязного состояния" между тестами и делает прогон стабильнее.

Код до yield — setup, после — cleanup (закрыть соединение, откатить транзакцию).


conftest.py

Файл tests/conftest.py хранит фикстуры, общие для всей папки tests/ (клиент API, подключение к тестовой БД). Импортировать в каждом файле их не нужно — pytest подхватывает сам.


FastAPI — TestClient

Тестируем API без запуска uvicorn и без реального TCP — быстрее и стабильнее.

Код ITЗагрузка примера кода…

Разбор:

  • TestClient(app) запускает FastAPI-приложение в памяти без отдельного сервера.
  • client.get(...) и client.post(...) имитируют реальные HTTP-вызовы.
  • json={...} сериализует тело запроса и ставит нужный Content-Type.
  • Проверка 422 подтверждает, что валидация входных данных работает корректно.
  • Такой стиль тестов быстрый и хорошо подходит для CI-пайплайнов.
ВыражениеСмысл
client.get("/health")Симулировать GET
r.status_codeHTTP-код: 200, 201, 404, 422
r.json()Распарсенное тело JSON
json={"text": "..."}Тело POST + заголовок Content-Type

TestClient поднимает ASGI-приложение в памяти того же процесса, что и тесты.


JWT в тестах

Код ITЗагрузка примера кода…

Сначала получаем токен, потом передаём заголовок — как настоящий клиент после логина.


Flask — тестовый клиент

Код ITЗагрузка примера кода…

TESTING = True отключает некоторые "боевые" эффекты (например, строгие cookies). Паттерн тот же: фикстура client, запросы get/post.


Что тестировать в первую очередь

  1. Бизнес-логика без HTTP — быстрые тесты, мало зависимостей.
  2. HTTP-контракт — коды 200, 400, 401, 422 и форма JSON.
  3. Coverage (pytest --cov=myapp) — ориентир, а не цель 100 % на старте. Сначала критичные пути — оплата, регистрация, сохранение данных.

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

СимптомПричинаРешение
ModuleNotFoundErrorЗапуск не из корняcd в корень или pip install -e .
Тесты видят "старые" данныеОбщий список в модуле мутируетсяНовая фикстура на каждый тест
401 в API-тестахНет заголовка AuthorizationФикстура auth_headers
Падает только в CIЧитает production .envmonkeypatch.setenv в тесте

monkeypatch (встроенная фикстура pytest) подменяет переменные окружения на время теста.


Что попробовать

pip install pytest-cov
pytest --cov=myapp

Разбор:

  • pytest-cov подключает сбор метрики покрытия кода.
  • pytest --cov=myapp показывает, какая доля модулей myapp реально исполняется тестами.
  • Метрика помогает находить критичные участки без автотестов.
  • Покрытие стоит использовать как ориентир качества, а не как единственную цель.
  1. Встроенная фикстура tmp_path — файлы во временной папке.
  2. pytest.raises(ValueError) — ожидаемое исключение:

import pytest

def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
1 / 0

Связанные материалы


Как строить тесты в реальном проекте

Когда проект растёт, тесты удобно раскладывать по уровням. Это снижает хаос и делает отчёт pytest читаемым даже через полгода.

УровеньЧто проверяетИнструменты
UnitОтдельную функцию без сети и БДpytest, parametrize, monkeypatch
IntegrationСвязку модулей и БДpytest, тестовая БД, фикстуры
APIHTTP-контракт сервисаTestClient, статус-коды, JSON-схемы
SmokeКритические сценарии после деплоянебольшой набор быстрых тестов

Практический подход:

  1. Для каждой новой функции добавляйте хотя бы один unit-тест.
  2. Для каждого нового эндпоинта добавляйте успешный и неуспешный API-кейс.
  3. Для багов из продакшена сначала пишите тест, который воспроизводит ошибку, и только потом фикс.

Мини-чек-лист качества тестов

Перед коммитом прогоните короткую проверку:

  • тесты запускаются командой pytest -q без ручной подготовки;
  • названия тестов читаются как сценарии (test_create_note_without_text_returns_422);
  • в фикстурах нет скрытого глобального состояния;
  • у ошибок проверяется код и структура detail;
  • критические пути покрыты минимум одним позитивным и одним негативным сценарием.

Практикум по тестовым библиотекам

Responses — мок внешнего HTTP для requests


import requests
import responses

@responses.activate
def test_external_profile():
responses.get(
"https://api.example.com/profile/1",
json={"id": 1, "name": "Alice"},
status=200,
)
r = requests.get("https://api.example.com/profile/1", timeout=2)
assert r.json()["name"] == "Alice"

Разбор:

  • @responses.activate перехватывает HTTP-вызовы requests внутри теста.
  • responses.get(...) задаёт мок-ответ для конкретного URL.
  • Реальный requests.get(...) не уходит в сеть, а получает подготовленный фиктивный ответ.
  • assert ... проверяет бизнес-результат, а не транспорт.
  • Изоляция от сети делает тесты предсказуемыми и не флейковыми.

Freezegun — фиксированное время

from datetime import datetime
from freezegun import freeze_time

@freeze_time("2026-01-15 10:00:00")
def test_invoice_date():
assert datetime.now().strftime("%Y-%m-%d") == "2026-01-15"

Разбор:

  • @freeze_time(...) замораживает "текущее время" в рамках теста.
  • datetime.now() возвращает предсказуемое значение, независимое от реальных часов.
  • Тест на даты перестаёт быть flaky и стабильно проходит в CI.
  • Это особенно полезно для логики дедлайнов, TTL и окон активности.

Faker и factory_boy для фабрик

from faker import Faker

import factory

fake = Faker("ru_RU")

class UserFactory(factory.DictFactory):
name = factory.LazyFunction(fake.name)
email = factory.LazyFunction(fake.email)

Разбор:

  • Faker создаёт реалистичные тестовые значения.
  • factory.DictFactory оформляет единый шаблон тестовой сущности.
  • LazyFunction генерирует новые значения при каждом вызове фабрики.
  • Такой подход уменьшает ручное копирование фикстур и ускоряет написание тестов.
  • Фабрики помогают централизованно менять структуру тестовых данных в одном месте.

pytest-asyncio для async-кода


import pytest

@pytest.mark.asyncio
async def test_async_sum():
async def add(a: int, b: int) -> int:
return a + b
assert await add(2, 3) == 5

Разбор:

  • @pytest.mark.asyncio запускает тест в asyncio event loop.
  • async def ... позволяет проверять асинхронный код в натуральном формате.
  • await add(...) дожидается результата корутины.
  • Такой шаблон используют для тестирования async-сервисов, репозиториев и клиентов API.
  • Без этого маркера корутины не выполнятся корректно в обычном синхронном тест-раннере.
Основа по протоколу

Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.