Тестирование backend: стратегия, а не 100% coverage
100% покрытие кода тестами — это не цель. Это метрика, которую легко обмануть и которая почти ничего не говорит о реальном качестве продукта. Можно написать тесты, которые вызывают каждую строку кода, но не проверяют ни одного реального поведения. Баги при этом живут и процветают, потому что тестируются строки, а не контракты.
Правильный вопрос не «сколько процентов покрыто», а «что сломается, если этот код упадёт в production». Начинаем с этого и строим стратегию тестирования вокруг рисков, а не метрик.
Testing pyramid vs. trophy: что выбрать
Testing pyramid (Майк Кон, 2009): основание — много unit-тестов, середина — integration, верхушка — немного e2e. Логика: unit быстрые и дешёвые, e2e медленные и хрупкие.
Testing trophy (Кент Додс, 2018): акцент смещается на integration-тесты. Основание — статический анализ (types, linters), slim-слой unit (чистая бизнес-логика), широкий слой integration (API endpoint + real DB), верхушка — немного e2e.
Для backend-разработки trophy-модель точнее отражает реальность. Большинство backend-багов — это не ошибки в изолированных функциях, а проблемы на стыках: неправильный SQL-запрос, неожиданное поведение ORM, race condition в бизнес-логике с реальной БД. Unit-тест с моком PostgreSQL этого не поймает.
Практическое правило: используйте unit-тесты для чистой бизнес-логики (алгоритмы, трансформации данных, валидация) и integration-тесты для всего, что взаимодействует с инфраструктурой.
Integration testing с Testcontainers
Testcontainers — библиотека для запуска Docker-контейнеров из тестового кода. Вместо мока PostgreSQL поднимается реальный PostgreSQL в изолированном контейнере, тест работает с ним, контейнер удаляется после завершения.
# Python: testcontainers-python
# pip install testcontainers[postgres]
import pytest
from testcontainers.postgres import PostgresContainer
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
@pytest.fixture(scope="session")
def postgres_container():
with PostgresContainer("postgres:16-alpine") as postgres:
yield postgres
@pytest.fixture(scope="session")
def db_engine(postgres_container):
engine = create_engine(postgres_container.get_connection_url())
# Применяем миграции к тестовой БД
from alembic.config import Config
from alembic import command
cfg = Config("alembic.ini")
cfg.set_main_option("sqlalchemy.url", postgres_container.get_connection_url())
command.upgrade(cfg, "head")
yield engine
engine.dispose()
@pytest.fixture
def db_session(db_engine):
"""Каждый тест получает транзакцию, которая откатывается после."""
connection = db_engine.connect()
transaction = connection.begin()
Session = sessionmaker(bind=connection)
session = Session()
yield session
session.close()
transaction.rollback()
connection.close()
# Реальный integration-тест с PostgreSQL:
def test_create_user(db_session):
db_session.execute(
text("INSERT INTO users (email, hashed_password) VALUES (:e, :p)"),
{"e": "test@example.com", "p": "hashed"}
)
db_session.flush()
row = db_session.execute(
text("SELECT email FROM users WHERE email = :e"),
{"e": "test@example.com"}
).fetchone()
assert row is not None
assert row.email == "test@example.com"
def test_unique_email_constraint(db_session):
"""Проверяем реальный UNIQUE constraint PostgreSQL."""
from sqlalchemy.exc import IntegrityError
db_session.execute(
text("INSERT INTO users (email, hashed_password) VALUES (:e, :p)"),
{"e": "dup@example.com", "p": "hash"}
)
db_session.flush()
with pytest.raises(IntegrityError): # не мок — реальный PostgreSQL
db_session.execute(
text("INSERT INTO users (email, hashed_password) VALUES (:e, :p)"),
{"e": "dup@example.com", "p": "hash"}
)
db_session.flush()
Test fixtures — фабрики тестовых данных. Не создавайте данные вручную в каждом тесте — поддерживать это невозможно. Используйте fixture factories:
# conftest.py: factory fixtures с faker
import pytest
from faker import Faker
fake = Faker()
@pytest.fixture
def user_factory(db_session):
def _create(email=None, role="user", **kwargs):
email = email or fake.email()
db_session.execute(
text(
"INSERT INTO users (email, hashed_password, role) "
"VALUES (:e, :p, :r) RETURNING id"
),
{"e": email, "p": "fake_hash", "r": role, **kwargs}
)
db_session.flush()
return db_session.execute(
text("SELECT * FROM users WHERE email = :e"), {"e": email}
).fetchone()
return _create
# Использование в тестах:
def test_admin_can_delete_user(db_session, user_factory):
admin = user_factory(role="admin")
target = user_factory()
# тест логики удаления...
assert admin.role == "admin"
FastAPI integration tests с TestClient и реальной БД:
# tests/test_api.py
import pytest
from httpx import AsyncClient
from app.main import app
from app.database import get_db
@pytest.fixture
def client(db_session):
"""Override DB dependency с тестовой сессией."""
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
def test_create_article(client, user_factory):
user = user_factory(role="editor")
response = client.post(
"/api/articles",
json={"title": "Test Article", "body": "Content here"},
headers={"Authorization": f"Bearer {create_test_token(user.id)}"},
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "Test Article"
assert "id" in data
def test_create_article_unauthorized(client):
response = client.post(
"/api/articles",
json={"title": "Test", "body": "Content"},
)
assert response.status_code == 401
Property-based testing: Hypothesis и fast-check
Обычные тесты проверяют конкретные примеры: «при входе X получаем Y». Property-based testing переворачивает подход: описываем свойство функции (инвариант, который должен выполняться всегда), а фреймворк генерирует сотни случайных входных данных, пытаясь это свойство нарушить.
# Python: Hypothesis
# pip install hypothesis
from hypothesis import given, settings, assume
from hypothesis import strategies as st
import pytest
# Пример: тестирование функции пагинации
def paginate(items: list, page: int, per_page: int) -> list:
start = (page - 1) * per_page
return items[start:start + per_page]
@given(
items=st.lists(st.integers(), min_size=0, max_size=100),
page=st.integers(min_value=1, max_value=20),
per_page=st.integers(min_value=1, max_value=50),
)
def test_paginate_never_exceeds_per_page(items, page, per_page):
"""Свойство: результат пагинации никогда не больше per_page элементов."""
result = paginate(items, page, per_page)
assert len(result) <= per_page
@given(
items=st.lists(st.integers(), min_size=1, max_size=100),
page=st.integers(min_value=1, max_value=10),
per_page=st.integers(min_value=1, max_value=20),
)
def test_paginate_items_are_subset(items, page, per_page):
"""Свойство: все элементы результата присутствуют в оригинальном списке."""
result = paginate(items, page, per_page)
for item in result:
assert item in items
# Hypothesis сохраняет failing cases и воспроизводит их при следующем запуске:
# ~/.hypothesis/examples/
# Тестирование Pydantic-схем с Hypothesis:
from pydantic import BaseModel, EmailStr
from hypothesis import given
from hypothesis_pydantic import from_schema
class UserCreate(BaseModel):
email: EmailStr
name: str
age: int
@given(from_schema(UserCreate))
def test_user_schema_always_serializable(user: UserCreate):
"""Любой валидный UserCreate должен сериализоваться в JSON без ошибок."""
import json
json_str = user.model_dump_json()
parsed = json.loads(json_str)
assert parsed["email"] == user.email
// TypeScript: fast-check
// npm install -D fast-check
import * as fc from "fast-check";
import { describe, it, expect } from "vitest";
// Пример: тестирование функции нормализации email
function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
describe("normalizeEmail", () => {
it("is idempotent (applying twice = applying once)", () => {
fc.assert(
fc.property(fc.emailAddress(), (email) => {
const once = normalizeEmail(email);
const twice = normalizeEmail(once);
expect(once).toBe(twice);
})
);
});
it("result always lowercase", () => {
fc.assert(
fc.property(fc.string(), (s) => {
const result = normalizeEmail(s);
expect(result).toBe(result.toLowerCase());
})
);
});
});
// Тестирование API-трансформаций:
describe("price calculation", () => {
it("discount never produces negative price", () => {
fc.assert(
fc.property(
fc.float({ min: 0, max: 10000 }), // цена
fc.float({ min: 0, max: 1 }), // скидка 0–100%
(price, discount) => {
const result = applyDiscount(price, discount);
expect(result).toBeGreaterThanOrEqual(0);
}
)
);
});
});
Hypothesis особенно эффективен для: парсеров и сериализаторов (roundtrip свойство), математических операций (коммутативность, ассоциативность), функций нормализации и трансформации данных, граничных условий в алгоритмах.
Mutation testing: реальное качество тестов
Coverage говорит, что строки были выполнены. Mutation testing говорит, что тесты действительно защищают от ошибок. Мутационный тест инструментирует ваш код: меняет > на >=, убирает блоки if, меняет возвращаемые значения — и проверяет, упадут ли при этом ваши тесты. Если мутация выживает (тесты не поймали изменение), значит, тест неполный.
# Python: mutmut
# pip install mutmut
# Запуск мутационного тестирования:
mutmut run --paths-to-mutate=app/services/
# Просмотр выживших мутаций:
mutmut results
# Детали конкретного выжившего:
mutmut show 42
# Типичный вывод:
# Mutation id 42:
# --- app/services/pricing.py
# +++ app/services/pricing.py
# @@ -15,7 +15,7 @@
# def apply_discount(price: float, pct: float) -> float:
# - if pct > 100:
# + if pct >= 100:
# raise ValueError("Discount cannot exceed 100%")
# return price * (1 - pct / 100)
# Эта мутация выжила — значит, тест не проверяет граничный случай pct == 100
# TypeScript: Stryker
# npm install -D @stryker-mutator/core @stryker-mutator/vitest-runner
# stryker.config.mjs:
# export default {
# packageManager: "npm",
# reporters: ["html", "clear-text"],
# testRunner: "vitest",
# coverageAnalysis: "perTest",
# mutate: ["src/**/*.ts", "!src/**/*.spec.ts"],
# };
npx stryker run
# Stryker генерирует HTML-отчёт с mutation score
# Mutation score = (killed mutations / total mutations) * 100
# Цель: > 80% — хороший порог для бизнес-логики
Mutation testing не нужно запускать при каждом коммите — это дорого (время). Запускайте его раз в неделю или перед релизом критических компонентов. Используйте как диагностику: низкий mutation score в services/billing.py — сигнал, что этот модуль недотестирован, несмотря на высокий coverage.
Contract testing с Pact
В микросервисной архитектуре интеграционные тесты между сервисами обычно либо дорогие (поднимать все сервисы вместе), либо ненадёжные (моки устаревают). Contract testing решает это: consumer описывает контракт (что он ожидает от API), provider проверяет, что реально соответствует этому контракту.
# Consumer side (фронтенд или другой сервис):
# pip install pact-python
from pact import Consumer, Provider
import requests
pact = Consumer("frontend").has_pact_with(Provider("user-api"))
def test_get_user():
expected_user = {
"id": 1,
"email": "test@example.com",
"role": "user",
}
(
pact
.given("User with ID 1 exists")
.upon_receiving("A request for user 1")
.with_request("GET", "/api/users/1")
.will_respond_with(200, body=expected_user)
)
with pact:
response = requests.get("http://localhost:1234/api/users/1")
assert response.json() == expected_user
# Pact сохраняет контракт в pacts/frontend-user-api.json
# Provider должен его проверить
# Provider side (user-api сервис):
from pact import Verifier
def test_provider_honors_contracts():
verifier = Verifier(
provider="user-api",
provider_base_url="http://localhost:8000",
)
output, _ = verifier.verify_pacts(
"./pacts/",
provider_states_setup_url="http://localhost:8000/_pact/provider-states",
)
assert output == 0, "Provider does not honor all consumer contracts"
Contract testing особенно ценен при независимом деплое сервисов: прежде чем деплоить user-api, CI проверяет, что все consumer-контракты выполняются. Это даёт уверенность без полноценного integration environment.
Что НЕ тестировать
Правильная стратегия — это не только то, что тестировать, но и что не тестировать. Тесты, которые проверяют детали реализации, а не поведение, — источник боли при рефакторинге.
- Не тестируйте ORM-маппинг. Проверять, что
User.emailсохраняется в колонкуemail— это тестирование SQLAlchemy, а не вашего кода. - Не тестируйте приватные методы напрямую. Если приватный метод нужно тестировать отдельно — это сигнал, что его пора выносить в отдельный класс.
- Не тестируйте getters/setters без логики.
user.name = "Alice"; assert user.name == "Alice"— это не тест. - Не дублируйте unit-тесты в integration. Если функция уже покрыта unit-тестами, integration-тест должен проверять взаимодействие, а не те же граничные случаи.
- Не тестируйте third-party библиотеки. Вы не тестируете, что
requests.getделает HTTP-запрос. Тестируйте своё использование.
# Плохо: тест деталей реализации
def test_user_has_email_attribute():
user = User(email="a@b.com")
assert user.email == "a@b.com" # это не поведение
# Хорошо: тест поведения
def test_register_sends_confirmation_email(client, mock_email_sender):
response = client.post(
"/api/auth/register",
json={"email": "new@example.com", "password": "SecurePass123"}
)
assert response.status_code == 201
mock_email_sender.assert_called_once_with(
to="new@example.com",
subject="Подтвердите email"
)
Хорошая тестовая стратегия — это инвестиция, которая окупается при рефакторинге. Если изменение поведения ломает тесты — отлично, так и должно быть. Если рефакторинг (без изменения поведения) ломает тесты — тесты тестируют не то.
Цель тестирования — уверенность в деплое, а не метрика в отчёте. Один хорошо написанный integration-тест, проверяющий реальный сценарий с реальной базой, стоит больше, чем двадцать unit-тестов с моками, покрывающих те же строки.