← все статьи
7 мин

Тестирование 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-тестов с моками, покрывающих те же строки.

← Предыдущая Аутентификация в 2025... Следующая →Кэширование: от HTTP-заголовков до distributed cache