Тестирование ПО Интеграционное тестирование, тестовые контейнеры и контрактные тесты
0%

Интеграционное тестирование, тестовые контейнеры и контрактные тесты

Интеграционное тестирование, тестовые контейнеры и контрактные тесты

Есть классическая картинка: два ящика с надписями «unit tests: 100% passing», между которыми летит мяч и не попадает ни в один. Это не шутка про качество кода — это точное описание того, где живут дефекты после того, как вы честно написали модульные тесты по правилам из статьи про юнит-тесты. Каждый модуль корректен относительно своего мока. Система не работает.

Причина в том, что юнит-тест проверяет модуль относительно вашего представления о соседе. Мок — это застывшая гипотеза: «репозиторий вернёт None, если пользователя нет», «биллинг ответит 402 при недостатке средств». Гипотеза может быть ложной в момент написания и почти наверняка станет ложной через полгода, потому что сосед меняется, а ваш мок — нет. Интеграционное тестирование — это дисциплина проверки самих гипотез: не «правильно ли я обрабатываю ответ», а «правда ли ответ такой».

Что вообще считать интеграционным тестом

Здесь начинается терминологический бардак, и его стоит разобрать сразу, потому что он реально мешает командам договариваться.

Классическое определение (ISTQB, V-модель): уровень тестирования между модульным и системным, проверяющий взаимодействие между компонентами или системами. Уровень определяется по объекту тестирования — «интерфейсы между модулями». Тестировщик мыслит именно так: есть уровни, у каждого свой вход и выход, интеграционное тестирование начинается, когда модули собраны.

Определение разработчика (Fowler, «Integration Test» на martinfowler.com): здесь важно различать narrow и broad integration tests. Узкий интеграционный тест проверяет ровно один участок кода, который общается с внешним сервисом, и поднимает только этот сервис (или его тестового двойника). Широкий — требует живых экземпляров всех зависимостей, и по факту это то, что многие называют E2E.

Расхождение взглядов, о котором стоит сказать честно. Тестировщик спрашивает: «какие интерфейсы между подсистемами покрыты?» — и мыслит матрицей взаимодействий. Разработчик спрашивает: «где проходит граница моего процесса и что я могу подменить?» — и мыслит стоимостью прогона. Эти вопросы не противоречат, но приводят к разным наборам тестов. Продуктивный компромисс: классифицировать тест не по названию, а по двум измерениям — сколько компонентов участвует и что из них настоящее. Дальше уже понятно, куда его класть в CI и сколько он имеет права выполняться.

Охват разных типов тестов: от юнита до E2E

Практическая договорённость, которая работает в большинстве команд:

Тип Что настоящее Где живёт Бюджет времени
Юнит ничего внешнего рядом с кодом < 10 мс на тест
Узкий интеграционный одна зависимость (БД, брокер) в контейнере отдельный каталог/тег < 2 с на тест
Контрактный ничего живого, но контракт верифицируется у обеих сторон в обоих репозиториях секунды
Широкий интеграционный ваш сервис + соседи в контейнерах отдельный пайплайн минуты
E2E развёрнутый стенд ночной прогон / pre-release десятки минут

Что именно ломается на стыках

Прежде чем писать тесты, полезно знать таксономию дефектов интеграции — иначе вы будете писать широкие тесты «на всякий случай», а они не найдут ничего, что не нашли бы юниты.

Обратите внимание: почти ничего из этого списка не проверяется моком. Мок по определению воспроизводит ваше представление о соседе, а весь список — это места, где представление разошлось с реальностью. Отсюда главный принцип интеграционного тестирования: тестируйте те стыки, где ваше знание о соседе может устареть без вашего ведома.

Чем подменять зависимость: дерево решений

Самый частый вопрос на код-ревью — «а тут мок или контейнер?». Ответ зависит не от вкуса, а от того, кто владеет зависимостью и насколько её поведение сложное.

Ключевая мысль про правую ветку: любой записанный ответ протухает. Если вы держите WireMock-стабы для внешнего платёжного API, у вас обязан быть отдельный редкий тест (раз в сутки, не в PR-пайплайне), который дёргает живой sandbox и сравнивает схему ответа с той, что зашита в стабах. Иначе первый, кто узнает об изменении вендора, — это прод.

Testcontainers: реальная БД вместо иллюзии

Долгие годы стандартной практикой было подменять PostgreSQL на SQLite или H2 «ради скорости». Это работает ровно до первого ON CONFLICT DO UPDATE, оконной функции, jsonb-оператора, частичного индекса или разницы в поведении NULL при сортировке. Вы получаете зелёные тесты и красный прод — худший из возможных исходов, потому что тесты активно врут.

Testcontainers решает это прямо: тест сам поднимает docker-контейнер с настоящей зависимостью, отдаёт вам адрес и порт, а по окончании убирает за собой. Библиотека есть для Java, Go, Python, .NET, Node.js, Rust.

# tests/conftest.py — Python, pytest + SQLAlchemy + Testcontainers
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session
from testcontainers.postgres import PostgresContainer

from myapp.db.migrations import upgrade_to_head


@pytest.fixture(scope="session")
def pg_engine():
    """Один контейнер на всю сессию тестов: старт стоит 1-3 секунды,
    поднимать его на каждый тест — верный способ получить прогон на 20 минут."""
    with PostgresContainer("postgres:16.4-alpine") as pg:
        # ВАЖНО: тот же minor-тег, что и в проде. postgres:latest в тестах —
        # это гарантия однажды проснуться от падения после чужого релиза.
        url = pg.get_connection_url()          # postgresql+psycopg2://...:<случайный порт>
        engine = create_engine(url, future=True)
        upgrade_to_head(url)                   # прогоняем ТЕ ЖЕ миграции, что и в проде
        yield engine
        engine.dispose()


@pytest.fixture
def session(pg_engine):
    """Изоляция между тестами через внешнюю транзакцию с откатом.
    Данные не утекают из теста в тест, а пересоздавать схему не нужно."""
    conn = pg_engine.connect()
    outer = conn.begin()
    s = Session(bind=conn, join_transaction_mode="create_savepoint")
    try:
        yield s
    finally:
        s.close()
        outer.rollback()   # всё, что натворил тест, исчезает
        conn.close()

Тест на репозитории — тот случай, где интеграционный тест находит то, что юнит найти не мог в принципе:

# tests/integration/test_order_repository.py
import pytest
from psycopg2.errors import UniqueViolation
from sqlalchemy.exc import IntegrityError

from myapp.repositories import OrderRepository
from myapp.models import Order


def test_идемпотентное_создание_заказа(session):
    """Проверяем реальное поведение ON CONFLICT, а не наше представление о нём."""
    repo = OrderRepository(session)
    first = repo.create_idempotent(user_id="u-42", idem_key="k-1", amount_cents=19900)
    second = repo.create_idempotent(user_id="u-42", idem_key="k-1", amount_cents=19900)

    assert first.id == second.id, "повторный вызов с тем же ключом обязан вернуть тот же заказ"
    assert session.query(Order).count() == 1


def test_частичный_уникальный_индекс_не_мешает_отменённым(session):
    """Бизнес-правило: активный заказ на слот может быть один,
    но отменённых на тот же слот — сколько угодно.
    Реализовано частичным индексом, которого нет ни в SQLite, ни в моке."""
    repo = OrderRepository(session)
    repo.create(user_id="u-1", slot="2026-08-01T10:00", status="cancelled")
    repo.create(user_id="u-2", slot="2026-08-01T10:00", status="cancelled")
    repo.create(user_id="u-3", slot="2026-08-01T10:00", status="active")
    session.flush()

    with pytest.raises(IntegrityError) as exc:
        repo.create(user_id="u-4", slot="2026-08-01T10:00", status="active")
        session.flush()
    assert isinstance(exc.value.orig, UniqueViolation)


def test_сортировка_с_null_соответствует_ожиданиям_продукта(session):
    """NULLS LAST в PostgreSQL при DESC — не то же самое, что в MySQL или SQLite.
    Продукт требует: заказы без даты доставки идут в конце списка."""
    repo = OrderRepository(session)
    repo.create(user_id="u-1", deliver_at=None)
    repo.create(user_id="u-2", deliver_at="2026-08-02")
    repo.create(user_id="u-3", deliver_at="2026-08-05")
    session.flush()

    ordered = [o.user_id for o in repo.list_by_delivery_desc()]
    assert ordered == ["u-3", "u-2", "u-1"]

Три теста — три класса дефектов, которые мок пропустил бы полностью, потому что в моке ON CONFLICT работает так, как вы его написали в моке.

Стоимость и как её удерживать

Testcontainers не бесплатны, и честно назвать цену важнее, чем расхваливать инструмент:

  • Старт контейнера — от 0.5 с (Redis, alpine-образы) до 10–20 с (Kafka с ZooKeeper, Elasticsearch, полновесная Oracle). Kafka в режиме KRaft поднимается заметно быстрее.
  • Docker в CI — нужен docker-сокет. В GitHub Actions на ubuntu-runner он есть из коробки; в Kubernetes-раннерах вам понадобится DinD-сайдкар или удалённый docker-хост, и это инфраструктурная работа на несколько дней.
  • Дисковое место и сеть — образы надо тянуть. Без кэша прогон в CI начинается с двухминутной паузы на docker pull.

Приёмы, которые реально работают:

# 1. Один контейнер на сессию (scope="session"), а лучше — на весь прогон xdist
#    через фикстуру с файловой блокировкой.

# 2. Локально — переиспользование контейнера между запусками.
#    Требует testcontainers.reuse.enable=true в ~/.testcontainers.properties.
#    В CI НЕ включать: там контейнер должен умирать вместе с job.
pg = PostgresContainer("postgres:16.4-alpine").with_reuse(True)

# 3. tmpfs для данных: БД в тестах не переживает падение, зачем ей fsync.
pg = (PostgresContainer("postgres:16.4-alpine")
      .with_tmpfs({"/var/lib/postgresql/data": "rw,size=512m"})
      .with_command("postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=off"))

# 4. Миграции — один раз на сессию, изоляция — транзакцией.
#    Пересоздание схемы на каждый тест превращает 300 тестов в 6 минут.

Для тяжёлых стеков есть шаблон «золотой образ»: собрать свой образ, где миграции уже применены и справочные данные загружены, и стартовать тесты с него. Экономит десятки секунд на прогон, платите за это необходимостью пересобирать образ при каждой новой миграции.

Изоляция тестовых данных: три стратегии

Выбор не абстрактный:

  • Rollback — по умолчанию. Ломается там, где тестируемый код сам управляет транзакциями (commit() внутри сервиса, SERIALIZABLE-повторы, тесты на блокировки). Для таких тестов делайте отдельную фикстуру.
  • TruncateTRUNCATE table1, table2 RESTART IDENTITY CASCADE одним запросом. Медленнее на 10–50 мс, зато честно работает с любым кодом. Список таблиц берите из information_schema, а не хардкодьте — забытая таблица даст плавающий тест через полгода.
  • Схема на тест (или база на воркер) — нужна при параллельном прогоне. pytest-xdist с --dist loadgroup плюс search_path на воркер даёт линейное ускорение.

Асинхронная интеграция: брокеры и порядок сообщений

Синхронный HTTP — простой случай. Настоящая боль начинается в очередях, где нет ответа, по которому можно судить об успехе. Пример на TypeScript с Kafka:

// tests/integration/order-consumer.test.ts — vitest + @testcontainers/kafka
import { KafkaContainer, StartedKafkaContainer } from "@testcontainers/kafka";
import { Kafka, Producer } from "kafkajs";
import { beforeAll, afterAll, expect, it, describe } from "vitest";
import { OrderConsumer } from "../../src/consumers/order-consumer";
import { InMemoryOrderStore } from "../support/in-memory-order-store";

let kafka: StartedKafkaContainer;
let producer: Producer;

beforeAll(async () => {
  // KRaft-режим: без ZooKeeper старт быстрее в разы
  kafka = await new KafkaContainer("confluentinc/cfk-kafka:7.6.1").withKraft().start();
  const client = new Kafka({ brokers: [`${kafka.getHost()}:${kafka.getMappedPort(9093)}`] });
  producer = client.producer();
  await producer.connect();
}, 120_000); // старт брокера — реально долго, таймаут по умолчанию не хватит

afterAll(async () => {
  await producer.disconnect();
  await kafka.stop();
});

describe("OrderConsumer", () => {
  it("обрабатывает дубликат сообщения ровно один раз", async () => {
    const store = new InMemoryOrderStore();
    const consumer = new OrderConsumer(store);
    await consumer.start();

    const event = { eventId: "e-1", orderId: "o-77", type: "OrderPaid", amountCents: 5000 };
    // Kafka гарантирует at-least-once: дубликат — не аномалия, а норма
    await producer.send({ topic: "orders", messages: [{ key: "o-77", value: JSON.stringify(event) }] });
    await producer.send({ topic: "orders", messages: [{ key: "o-77", value: JSON.stringify(event) }] });

    await waitFor(() => store.get("o-77")?.status === "paid");
    expect(store.appliedEvents("o-77")).toHaveLength(1); // идемпотентность
    await consumer.stop();
  });

  it("не теряет порядок для одного ключа", async () => {
    const store = new InMemoryOrderStore();
    const consumer = new OrderConsumer(store);
    await consumer.start();

    for (const type of ["OrderCreated", "OrderPaid", "OrderShipped"]) {
      await producer.send({
        topic: "orders",
        messages: [{ key: "o-88", value: JSON.stringify({ eventId: crypto.randomUUID(), orderId: "o-88", type }) }],
      });
    }

    await waitFor(() => store.get("o-88")?.status === "shipped");
    expect(store.statusHistory("o-88")).toEqual(["created", "paid", "shipped"]);
    await consumer.stop();
  });
});

/** Опрос вместо sleep: sleep(2000) — главный поставщик флаки-тестов в асинхронных сценариях. */
async function waitFor(cond: () => boolean, timeoutMs = 10_000, stepMs = 50): Promise<void> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    if (cond()) return;
    await new Promise((r) => setTimeout(r, stepMs));
  }
  throw new Error(`Условие не выполнилось за ${timeoutMs} мс`);
}

Отдельно подчеркну waitFor вместо sleep. Фиксированная пауза — либо слишком короткая (флак на загруженном CI), либо слишком длинная (прогон пухнет). Опрос с дедлайном решает обе проблемы. То же относится и к готовности контейнеров: не «подождать 5 секунд после старта», а Wait.forLogMessage(/database system is ready to accept connections/) или healthcheck.

Контрактные тесты: когда контейнеров становится слишком много

Testcontainers прекрасно работают с инфраструктурой. Но что делать, если ваш сервис зависит от семи чужих сервисов, каждый — от пяти своих? Поднять весь граф в тесте физически невозможно, а если возможно — прогон занимает полчаса и падает по причинам, не связанным с вашим кодом.

Контрактное тестирование разрывает этот граф. Идея: вместо того чтобы проверять, что сервисы работают вместе, мы проверяем, что каждый из них соблюдает договорённость о формате обмена — независимо и по отдельности. Это описано у Fowler в «Consumer-Driven Contracts» и реализовано в Pact.

Ключевое слово — consumer-driven: контракт пишет потребитель, а не поставщик. Причина в том, что поставщик не знает, какие именно поля из его ответа кому нужны. Если контракт составляет потребитель, поставщик получает точную карту: «эти три поля из сорока реально используются, остальные можно менять свободно».

Сторона потребителя (TypeScript)

// tests/contract/billing-client.pact.test.ts
import path from "node:path";
import { PactV3, MatchersV3 } from "@pact-foundation/pact";
import { describe, it, expect } from "vitest";
import { BillingClient } from "../../src/clients/billing-client";

const { like, integer, regex, eachLike } = MatchersV3;

const provider = new PactV3({
  consumer: "checkout-web",
  provider: "billing-api",
  dir: path.resolve(process.cwd(), "pacts"),
});

describe("BillingClient ↔ billing-api", () => {
  it("получает активную подписку пользователя", async () => {
    provider
      // provider state — договорённость: поставщик обязан уметь привести
      // себя в это состояние перед верификацией
      .given("у пользователя u-42 есть активная подписка pro")
      .uponReceiving("запрос подписки пользователя")
      .withRequest({
        method: "GET",
        path: "/v1/users/u-42/subscription",
        headers: { Accept: "application/json" },
      })
      .willRespondWith({
        status: 200,
        headers: { "Content-Type": "application/json" },
        // Матчеры вместо точных значений: контракт про ФОРМАТ, а не про данные.
        // like("pro") означает "строка", а не "именно pro".
        body: {
          plan: like("pro"),
          seats: integer(5),
          renewsAt: regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, "2026-08-01T00:00:00Z"),
          features: eachLike("sso"),
        },
      });

    await provider.executeTest(async (mockServer) => {
      const client = new BillingClient(mockServer.url);
      const sub = await client.getSubscription("u-42");
      // Проверяем, что КЛИЕНТ правильно распарсил ответ.
      // Это половина ценности: пакт ловит и ошибки поставщика, и ошибки клиента.
      expect(sub.plan).toBe("pro");
      expect(sub.seats).toBe(5);
      expect(sub.renewsAt.getUTCFullYear()).toBe(2026);
    });
  });

  it("корректно обрабатывает отсутствие подписки", async () => {
    provider
      .given("у пользователя u-99 нет подписки")
      .uponReceiving("запрос подписки для пользователя без подписки")
      .withRequest({ method: "GET", path: "/v1/users/u-99/subscription", headers: { Accept: "application/json" } })
      .willRespondWith({ status: 404, headers: { "Content-Type": "application/json" }, body: { code: like("SUBSCRIPTION_NOT_FOUND") } });

    await provider.executeTest(async (mockServer) => {
      const client = new BillingClient(mockServer.url);
      // 404 здесь — не ошибка, а нормальный бизнес-исход. Контракт это фиксирует.
      await expect(client.getSubscription("u-99")).resolves.toBeNull();
    });
  });
});

Сторона поставщика (Python)

# tests/contract/test_provider_verification.py
import os
import pytest
from pact import Verifier

from myapp.testing import spawn_app_with_states


@pytest.fixture(scope="module")
def running_provider():
    """Поднимаем реальный billing-api с реальной БД в контейнере,
    плюс служебный endpoint /_pact/provider-states для установки состояний."""
    with spawn_app_with_states(port=8080) as app:
        yield app


def test_пакты_потребителей_выполняются(running_provider):
    verifier = Verifier(provider="billing-api", provider_base_url="http://localhost:8080")
    success, logs = verifier.verify_with_broker(
        broker_url=os.environ["PACT_BROKER_BASE_URL"],
        broker_token=os.environ["PACT_BROKER_TOKEN"],
        publish_version=os.environ["GIT_COMMIT"],
        publish_verification_results=True,
        provider_states_setup_url="http://localhost:8080/_pact/provider-states",
        # Проверяем не все пакты подряд, а те, что реально в проде и в основной ветке
        consumer_version_selectors=[
            {"mainBranch": True},
            {"deployedOrReleased": True},
        ],
        enable_pending=True,          # новые пакты не ломают сборку поставщика сразу
        include_wip_pacts_since="2026-01-01",
    )
    assert success == 0, logs

Обработчик состояний — это то, что чаще всего недооценивают:

# myapp/testing/provider_states.py
from fastapi import APIRouter
from pydantic import BaseModel

router = APIRouter()

class StateRequest(BaseModel):
    state: str
    action: str  # "setup" | "teardown"

STATES = {
    "у пользователя u-42 есть активная подписка pro": lambda db: db.execute(
        "INSERT INTO subscriptions (user_id, plan, seats, renews_at) "
        "VALUES ('u-42', 'pro', 5, '2026-08-01T00:00:00Z') "
        "ON CONFLICT (user_id) DO UPDATE SET plan = EXCLUDED.plan"
    ),
    "у пользователя u-99 нет подписки": lambda db: db.execute(
        "DELETE FROM subscriptions WHERE user_id = 'u-99'"
    ),
}

@router.post("/_pact/provider-states")
def setup_state(req: StateRequest, db=Depends(get_db)):
    if req.action != "setup":
        return {"ok": True}
    handler = STATES.get(req.state)
    if handler is None:
        # Молча игнорировать неизвестное состояние — способ получить
        # зелёную верификацию, которая ничего не проверяет.
        raise ValueError(f"Неизвестное provider state: {req.state!r}")
    handler(db)
    return {"ok": True}

can-i-deploy: главная ценность, о которой забывают

Сами по себе пакты — это просто файлы. Ценность появляется, когда брокер отвечает на вопрос «безопасно ли выкатывать эту версию»:

# Перед деплоем поставщика: все ли потребители, что сейчас в проде,
# совместимы с моей версией?
pact-broker can-i-deploy \
  --pacticipant billing-api \
  --version "$GIT_COMMIT" \
  --to-environment production \
  --retry-while-unknown 12 --retry-interval 10

# После успешного деплоя — фиксируем факт
pact-broker record-deployment \
  --pacticipant billing-api --version "$GIT_COMMIT" --environment production

Именно этот шаг превращает контрактные тесты из «ещё одного набора тестов» в механизм, который физически не даёт выкатить ломающее изменение. Без can-i-deploy в пайплайне Pact — дорогая игрушка.

Схемы против пактов: когда Pact избыточен

Pact — не единственный и не всегда лучший способ. Если у вас типизированный протокол, значительную часть работы делает схема.

Подход Что ловит Чего не ловит Цена внедрения
OpenAPI + валидация запросов/ответов в тестах несоответствие формату, лишние/недостающие поля семантику, реально используемые поля низкая
gRPC/protobuf + buf breaking несовместимые изменения схемы на этапе PR изменение смысла поля низкая
Avro + Schema Registry с BACKWARD ломающие изменения формата событий ошибки логики потребителя средняя
Pact (CDC) ломающие изменения именно для реальных потребителей, ошибки клиента производительность, инфраструктуру высокая
Bidirectional (PactFlow) сверка схемы поставщика с ожиданиями потребителей без прогона у поставщика нюансы, не выразимые схемой средняя
# Дешёвая защита контракта для gRPC — работает в PR за секунды,
# не требует ни брокера, ни координации команд
buf breaking --against 'https://github.com/acme/protos.git#branch=main'

# Для Kafka + Avro: проверка совместимости схемы до публикации
curl -X POST -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  --data @order-paid-v2.json \
  http://schema-registry:8081/compatibility/subjects/orders-value/versions/latest

Практическое правило. Начинайте со схем: buf breaking, валидация против OpenAPI, BACKWARD-совместимость в Schema Registry. Это дёшево и снимает 70% дефектов контракта. Pact добавляйте там, где у вас много потребителей одного API и вы не знаете, какими полями они пользуются — то есть где схема разрешает изменение, а реальность нет. Внедрять Pact между двумя сервисами одной команды, которые релизятся вместе, — почти всегда лишняя работа.

Как решать, что тестировать на этом уровне

Интеграционные тесты — самая дорогая единица покрытия после E2E. Значит, выбор должен быть осознанным, а не «покроем все эндпоинты».

Риск-ориентированная процедура, которую можно применить за час на любом сервисе:

  1. Выпишите все стыки. Каждая зависимость: БД, кэш, брокер, каждый внешний HTTP-клиент, файловое хранилище, планировщик.
  2. Для каждого — оцените вероятность расхождения. Высокая: чужая команда, внешний вендор, схема без версионирования, частые релизы. Низкая: библиотека, зафиксированная версия, стабильный протокол.
  3. Оцените ущерб от отказа. Потеря денег, потеря данных, недоступность, косметика.
  4. Пересечение «высокая вероятность × высокий ущерб» — обязательные интеграционные тесты. Остальное — по остаточному принципу.

Отдельно про стоимость. Считайте не «сколько тестов», а минуты прогона и часы поддержки. Сервис с 40 интеграционными тестами по 1.5 с плюс 15 с на старт контейнеров — это чуть больше минуты, приемлемо для PR-пайплайна. Тот же сервис с 400 тестами по 2 с — 13 минут, и разработчики начнут коммитить с --no-verify. Установите бюджет заранее (например, «интеграционный слой ≤ 5 минут») и относитесь к его превышению как к дефекту: либо параллелить, либо часть проверок опускать на уровень юнитов.

Честно про боль

Флаки. Интеграционные тесты — второй по флакости слой после E2E. Источники, по убыванию частоты:

  • фиксированные sleep вместо ожидания условия;
  • общая БД между параллельными тестами (автоинкременты, глобальные таблицы, search_path);
  • неубранное состояние: тест A оставил запись, тест B её видит — и падает только при определённом порядке выполнения;
  • время: тесты, зависящие от «сегодня», ломаются в полночь и 29 февраля;
  • фиксированные порты — на CI-раннере уже кто-то занял 5432. Testcontainers по умолчанию выдаёт случайный порт, и хардкодить его — самая частая ошибка новичков;
  • нехватка ресурсов раннера: контейнер стартует за 3 с локально и за 25 с в CI под нагрузкой.

Рабочая политика: флак — это дефект с приоритетом, а не «перезапусти». Тест, упавший дважды за неделю без изменения кода, помечается карантином и чинится в течение спринта. Автоматический retry маскирует настоящие гонки в проде — подробнее в статье про тесты в CI.

Тестирование мока. Классический антипаттерн: тест поднимает WireMock, настраивает ответ, дёргает клиент и проверяет, что клиент вернул то, что настроили. Такой тест зелёный всегда и не проверяет ничего, кроме WireMock. Признак: в тесте нет ни одной проверки, которая могла бы упасть от изменения продакшн-кода, кроме сериализации. Лечение — либо контрактный тест, либо перенос проверки на уровень юнита с явным парсером.

Контракты, которые никто не поддерживает. Pact внедряют, потом поставщик отключает проверку «потому что она мешает релизиться», и через полгода это просто папка с JSON-файлами. Признаки деградации: enable_pending включён навсегда; провайдер верифицирует пакты не в своём пайплайне, а раз в неделю руками; can-i-deploy не блокирует деплой. Если этого нет — контрактное тестирование у вас в состоянии карго-культа.

Покрытие как самообман. Интеграционные тесты дают огромный прирост line coverage при почти нулевом приросте осмысленных проверок: один тест «дёрнули эндпоинт, получили 200» прогоняет пол-приложения. Не используйте покрытие как метрику качества интеграционного слоя — оно ровно здесь врёт сильнее всего. Полезнее считать покрытие стыков: сколько из выписанных на шаге 1 зависимостей имеют хотя бы один тест против реальной реализации.

Интеграционный слой в CI

# .github/workflows/ci.yml — разделение слоёв по времени и по стоимости
name: ci
on: [push, pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12", cache: pip }
      - run: pip install -r requirements-dev.txt
      # Юниты — быстрая обратная связь, падают первыми
      - run: pytest tests/unit -q --maxfail=1

  integration:
    runs-on: ubuntu-latest          # docker-сокет доступен из коробки
    needs: unit
    timeout-minutes: 15             # бюджет: превышение — повод разбираться
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12", cache: pip }
      - run: pip install -r requirements-dev.txt
      # Тянем образы заранее и параллельно старту тестов не мешаем
      - run: docker pull postgres:16.4-alpine & docker pull redis:7.4-alpine & wait
      - name: Интеграционные тесты
        env:
          TESTCONTAINERS_RYUK_DISABLED: "false"   # ryuk убирает мусор, если job упал
          TESTCONTAINERS_REUSE_ENABLE: "false"    # в CI переиспользование запрещено
        run: pytest tests/integration -q -n 4 --dist loadgroup --junitxml=report.xml
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: integration-report, path: report.xml }

  contract-consumer:
    runs-on: ubuntu-latest
    needs: unit
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "22", cache: npm }
      - run: npm ci
      - run: npm run test:contract          # генерирует pacts/
      - name: Публикуем пакт
        env:
          PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
        run: |
          npx pact-broker publish ./pacts \
            --consumer-app-version "$GITHUB_SHA" \
            --branch "${GITHUB_REF_NAME}"

  can-i-deploy:
    runs-on: ubuntu-latest
    needs: [integration, contract-consumer]
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Проверка совместимости с продом
        env:
          PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
        run: |
          npx pact-broker can-i-deploy \
            --pacticipant checkout-web --version "$GITHUB_SHA" \
            --to-environment production --retry-while-unknown 12

Обратите внимание на needs: unit: нет смысла жечь минуты на контейнеры, если не собирается базовое. И на timeout-minutes — без него зависший контейнер выест весь лимит раннеров.

Рабочие артефакты

Автоматизация не отменяет документации. Ниже — то, что реально живёт в трекере и в репозитории; подробнее о формате в статье про документацию.

Тест-кейс на интеграцию (ручной, для приёмки контракта)

ID: INT-BILL-014 Название: Обработка ответа 402 от billing-api при недостатке средств Уровень: интеграционный, узкий (billing-api в контейнере) Приоритет: P1 (влияет на выручку) Предусловия:

  1. checkout-web запущен с BILLING_URL, указывающим на тестовый экземпляр.
  2. Пользователь u-77 существует, баланс 0.

Шаги:

  1. Отправить POST /api/checkout с телом {"userId": "u-77", "planId": "pro"}.
  2. Дождаться ответа (таймаут 5 с).
  3. Запросить GET /api/checkout/{id}.

Ожидаемый результат:

  1. Шаг 1: HTTP 409, тело {"code": "INSUFFICIENT_FUNDS", "retryable": true}.
  2. Заказ в БД в статусе payment_failed, поле failure_reason = 'INSUFFICIENT_FUNDS'.
  3. В топик orders НЕ отправлено событие OrderPaid.
  4. В логах ровно одна запись уровня WARN, без стектрейса (это ожидаемый исход, не ошибка).

Пункты 3 и 4 — как раз то, что теряется при чисто юнитовом подходе: побочные эффекты на границах.

Чек-лист интеграции нового внешнего API

Перед тем как считать интеграцию готовой, пройдите список:

  • Таймаут на соединение и на чтение задан явно (не «по умолчанию бесконечность»).
  • Поведение при таймауте протестировано (контейнер с tc netem delay или toxiproxy).
  • Ретраи только для идемпотентных операций; для неидемпотентных — ключ идемпотентности.
  • Экспоненциальная задержка с джиттером, ограничение числа попыток.
  • Обработаны коды: 400, 401/403, 404, 409, 422, 429 (с Retry-After), 5xx.
  • Неизвестное значение enum не роняет парсинг, а логируется и попадает в unknown.
  • Ответ с лишними полями не ломает десериализацию (forward compatibility).
  • Проверено поведение при пустом теле и при Content-Type, отличном от ожидаемого.
  • Секреты не попадают в логи запроса/ответа.
  • Есть метрика: количество вызовов, латентность p99, доля ошибок по коду.
  • Есть circuit breaker или хотя бы ограничение параллелизма.
  • Записанные стабы (WireMock/VCR) имеют дату и владельца; есть задача на регулярную сверку.

Баг-репорт по дефекту интеграции

Заголовок: [billing-api] Поле renewsAt приходит без таймзоны после релиза 2.11.0 — checkout-web пишет дату со сдвигом −3 часа

Окружение: staging, checkout-web a3f19c2, billing-api 2.11.0, БД PostgreSQL 16.4

Шаги воспроизведения:

  1. curl -H 'Accept: application/json' https://billing.staging/v1/users/u-42/subscription
  2. Открыть страницу подписки в checkout-web для u-42.

Фактический результат: billing-api отдаёт "renewsAt": "2026-08-01T00:00:00" (без Z). checkout-web трактует значение как локальное время и показывает 31 июля, 21:00. В БД checkout сохраняется 2026-07-31T21:00:00Z.

Ожидаемый результат: "renewsAt": "2026-08-01T00:00:00Z" согласно контракту checkout-web ↔ billing-api (Pact, взаимодействие «запрос подписки пользователя», матчер regex требует суффикс Z).

Влияние: пользователи в UTC+3 видят дату списания на день раньше; в поддержку с 14:00 пришло 23 обращения.

Диагностика: в PR billing#4471 сериализатор переведён с Instant на LocalDateTime. Верификация пактов в пайплайне billing-api была пропущена: job помечен continue-on-error: true с 12.06 (billing#4390). Это корневая причина: контракт бы поймал изменение в PR.

Приоритет / серьёзность: P1 / Major

Хороший баг-репорт на интеграции почти всегда содержит сырой ответ соседа — без него спор «у меня работает» длится днями.

Типичные ошибки

  1. Подмена прод-СУБД на «похожую» в тестах. SQLite вместо PostgreSQL — зелёные тесты и красный прод. Testcontainers снимают этот компромисс полностью.
  2. postgres:latest в тестах. Однажды соберётся другой мажор, и вы будете чинить не свой код.
  3. Контейнер на каждый тест. 300 тестов × 2 с старта = 10 минут в чистом виде.
  4. Хардкод портов. Тест работает у вас и падает на раннере, где порт занят.
  5. sleep(3) вместо ожидания условия. Главный источник флаки в асинхронных тестах.
  6. Широкие интеграционные тесты вместо узких. Поднимать восемь сервисов, чтобы проверить парсинг даты, — это E2E, названный интеграцией, со всеми его недостатками.
  7. Провайдер-стейты, которые молча игнорируют неизвестное состояние. Верификация зелёная, проверок нет.
  8. Pact там, где хватило бы OpenAPI-валидации. Внедрение стоит недели, поддержка — постоянна.
  9. Контракты без can-i-deploy. Файлы есть, защиты нет.
  10. Оценка интеграционного слоя по line coverage. Метрика растёт, дефекты не находятся — ровно тот самообман, о котором говорилось в обзоре курса.
  11. Отсутствие бюджета времени. Слой без ограничения растёт, пока разработчики не начнут его обходить.
  12. Тесты, зависящие от порядка выполнения. Работают локально, разваливаются при -n 4.

Мини-итог

  • Интеграционное тестирование проверяет гипотезы о соседях, которые юнит-тесты фиксируют в моках и потому проверить не могут.
  • Различайте узкие и широкие интеграционные тесты. Узкие — рабочая лошадка: одна реальная зависимость, секунды на прогон. Широкие — это почти E2E, и относиться к ним надо так же строго.
  • Testcontainers убирают старый компромисс «реалистично или быстро»: реальная СУБД той же версии, что в проде, стартует за секунды. Держите один контейнер на сессию, изолируйте тесты транзакцией или truncate, фиксируйте minor-версию образа.
  • В асинхронных сценариях проверяйте идемпотентность, порядок и дубликаты — at-least-once доставка делает их обязательными, а не экзотикой.
  • Контрактные тесты разрывают комбинаторный взрыв «поднять весь граф сервисов»: каждая сторона проверяется отдельно против общего контракта. Ценность возникает только вместе с can-i-deploy в пайплайне.
  • Начинайте с дешёвых схемных проверок (buf breaking, OpenAPI-валидация, Schema Registry); Pact подключайте там, где у API много независимых потребителей.
  • Выбирайте объекты покрытия риск-ориентированно: «вероятность расхождения × ущерб». Считайте покрытие стыков, а не строк.
  • Держите бюджет времени и относитесь к флакам как к дефектам. Слой, который дольше пяти минут и падает через раз, будет обойдён — и вы вернётесь туда, откуда начинали.

Источники и что почитать дальше:

Что дальше

Мы дошли до границы, за которой начинается пользовательский интерфейс — самый хрупкий и самый дорогой слой автоматизации. Дальше: E2E и UI-тесты: Playwright, Selenium, борьба с хрупкостью.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов