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

Тестирование на 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

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

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 # один тест

Запускайте из корня проекта, где видны и 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 — одна функция, много кейсов

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

import pytest
from logic import normalize_title


@pytest.mark.parametrize(
"raw, expected",
[
("a", "a"),
(" b ", "b"),
("\tc\n", "c"),
],
)
def test_normalize_examples(raw, expected):
assert normalize_title(raw) == expected
ЭлементСмысл
@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"

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

  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 удалится автоматически

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

conftest.py

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


FastAPI — TestClient

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

# tests/test_api.py
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)


def test_health():
r = client.get("/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}


def test_create_note():
r = client.post("/notes", json={"text": "pytest"})
assert r.status_code == 201
body = r.json()
assert body["text"] == "pytest"
assert "id" in body


def test_create_note_validation():
r = client.post("/notes", json={"text": ""})
assert r.status_code == 422
ВыражениеСмысл
client.get("/health")Симулировать GET
r.status_codeHTTP-код: 200, 201, 404, 422
r.json()Распарсенное тело JSON
json={"text": "..."}Тело POST + заголовок Content-Type

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

JWT в тестах

@pytest.fixture
def auth_headers(client):
r = client.post(
"/token",
data={"username": "demo", "password": "demo123"},
)
token = r.json()["access_token"]
return {"Authorization": f"Bearer {token}"}


def test_notes_require_auth(client):
assert client.get("/notes").status_code == 401


def test_notes_with_token(client, auth_headers):
assert client.get("/notes", headers=auth_headers).status_code == 200

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


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

import pytest
from app import app

@pytest.fixture
def client():
app.config["TESTING"] = True
with app.test_client() as c:
yield c


def test_index(client):
r = client.get("/")
assert r.status_code == 200

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) подменяет переменные окружения на время теста.


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

  1. pip install pytest-covpytest --cov=myapp.
  2. Встроенная фикстура tmp_path — файлы во временной папке.
  3. pytest.raises(ValueError) — ожидаемое исключение:
import pytest

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

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


См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).