Интеграционное тестирование, тестовые контейнеры и контрактные тесты
Есть классическая картинка: два ящика с надписями «unit tests: 100% passing», между которыми летит мяч и не попадает ни в один. Это не шутка про качество кода — это точное описание того, где живут дефекты после того, как вы честно написали модульные тесты по правилам из статьи про юнит-тесты. Каждый модуль корректен относительно своего мока. Система не работает.
Причина в том, что юнит-тест проверяет модуль относительно вашего представления о соседе.
Мок — это застывшая гипотеза: «репозиторий вернёт None, если пользователя нет», «биллинг ответит
402 при недостатке средств». Гипотеза может быть ложной в момент написания и почти наверняка
станет ложной через полгода, потому что сосед меняется, а ваш мок — нет. Интеграционное
тестирование — это дисциплина проверки самих гипотез: не «правильно ли я обрабатываю ответ»,
а «правда ли ответ такой».
Что вообще считать интеграционным тестом
Здесь начинается терминологический бардак, и его стоит разобрать сразу, потому что он реально мешает командам договариваться.
Классическое определение (ISTQB, V-модель): уровень тестирования между модульным и системным, проверяющий взаимодействие между компонентами или системами. Уровень определяется по объекту тестирования — «интерфейсы между модулями». Тестировщик мыслит именно так: есть уровни, у каждого свой вход и выход, интеграционное тестирование начинается, когда модули собраны.
Определение разработчика (Fowler, «Integration Test» на martinfowler.com): здесь важно различать narrow и broad integration tests. Узкий интеграционный тест проверяет ровно один участок кода, который общается с внешним сервисом, и поднимает только этот сервис (или его тестового двойника). Широкий — требует живых экземпляров всех зависимостей, и по факту это то, что многие называют E2E.
Расхождение взглядов, о котором стоит сказать честно. Тестировщик спрашивает: «какие интерфейсы между подсистемами покрыты?» — и мыслит матрицей взаимодействий. Разработчик спрашивает: «где проходит граница моего процесса и что я могу подменить?» — и мыслит стоимостью прогона. Эти вопросы не противоречат, но приводят к разным наборам тестов. Продуктивный компромисс: классифицировать тест не по названию, а по двум измерениям — сколько компонентов участвует и что из них настоящее. Дальше уже понятно, куда его класть в CI и сколько он имеет права выполняться.
Практическая договорённость, которая работает в большинстве команд:
| Тип | Что настоящее | Где живёт | Бюджет времени |
|---|---|---|---|
| Юнит | ничего внешнего | рядом с кодом | < 10 мс на тест |
| Узкий интеграционный | одна зависимость (БД, брокер) в контейнере | отдельный каталог/тег | < 2 с на тест |
| Контрактный | ничего живого, но контракт верифицируется у обеих сторон | в обоих репозиториях | секунды |
| Широкий интеграционный | ваш сервис + соседи в контейнерах | отдельный пайплайн | минуты |
| E2E | развёрнутый стенд | ночной прогон / pre-release | десятки минут |
Что именно ломается на стыках
Прежде чем писать тесты, полезно знать таксономию дефектов интеграции — иначе вы будете писать широкие тесты «на всякий случай», а они не найдут ничего, что не нашли бы юниты.
интеграции")) Контракт данных Поле переименовано Тип изменился: число стало строкой Опциональное поле стало обязательным Enum пополнился неизвестным значением Формат даты и таймзона Семантика Одинаковое поле, разный смысл Единицы измерения: копейки или рубли Идентификаторы из разных пространств Протокол и транспорт Коды ошибок и их обработка Таймауты, ретраи, идемпотентность Пагинация и лимиты Сжатие, кодировка, размер тела Состояние и порядок Гонки при параллельных запросах Порядок сообщений в очереди Дубликаты после ретрая Незакрытые транзакции и блокировки Конфигурация и окружение Не тот адрес, не тот ключ Миграции БД не применены Различие версий: dev 15, prod 13 Часовой пояс и локаль контейнера
Обратите внимание: почти ничего из этого списка не проверяется моком. Мок по определению воспроизводит ваше представление о соседе, а весь список — это места, где представление разошлось с реальностью. Отсюда главный принцип интеграционного тестирования: тестируйте те стыки, где ваше знание о соседе может устареть без вашего ведома.
Чем подменять зависимость: дерево решений
Самый частый вопрос на код-ревью — «а тут мок или контейнер?». Ответ зависит не от вкуса, а от того, кто владеет зависимостью и насколько её поведение сложное.
и простая?"} B -->|"Да: чистая библиотека,
часы, генератор id"| C["Подмена в коде:
стаб или фейк-объект"] B -->|Нет| D{"Кто ей владеет?"} D -->|"Вы, но это инфраструктура:
БД, брокер, кэш, S3"| E["Реальная в контейнере
Testcontainers"] D -->|"Соседняя команда,
ваш же продукт"| F["Контрактный тест
плюс фейк для сценариев"] D -->|"Внешний вендор"| G{"Есть sandbox
и он стабилен?"} G -->|Да| H["Узкий тест против sandbox,
вне основного пайплайна"] G -->|Нет| I["Записанные ответы:
WireMock, VCR, MSW
плюс регулярная сверка"] E --> J["Проверяем: SQL, миграции,
индексы, транзакции,
сериализацию"] F --> K["Проверяем: формат обмена,
обратная совместимость"] I --> L["Риск дрейфа:
нужен canary-тест
против живого API"]
Ключевая мысль про правую ветку: любой записанный ответ протухает. Если вы держите 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-повторы, тесты на блокировки). Для таких тестов делайте отдельную фикстуру. - Truncate —
TRUNCATE 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: контракт пишет потребитель, а не поставщик. Причина в том, что поставщик не знает, какие именно поля из его ответа кому нужны. Если контракт составляет потребитель, поставщик получает точную карту: «эти три поля из сорока реально используются, остальные можно менять свободно».
checkout-web participant MS as Mock-сервер Pact participant BR as Pact Broker participant PT as Тесты поставщика
billing-api participant PS as Реальный billing-api CT->>MS: описываем ожидаемый запрос и ответ CT->>MS: реальный HTTP-запрос клиентского кода MS-->>CT: ответ по описанию Note over CT,MS: если клиент не отправил описанный запрос —
тест падает у потребителя CT->>BR: публикуем пакт с тегом ветки и версией PT->>BR: забираем все пакты моих потребителей loop по каждому взаимодействию PT->>PS: воспроизводим запрос из пакта PS-->>PT: настоящий ответ PT->>PT: сверяем со схемой из пакта end PT->>BR: публикуем результат верификации Note over BR: can-i-deploy: можно ли
выкатывать эту версию в prod?
Сторона потребителя (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. Значит, выбор должен быть осознанным, а не «покроем все эндпоинты».
Риск-ориентированная процедура, которую можно применить за час на любом сервисе:
- Выпишите все стыки. Каждая зависимость: БД, кэш, брокер, каждый внешний HTTP-клиент, файловое хранилище, планировщик.
- Для каждого — оцените вероятность расхождения. Высокая: чужая команда, внешний вендор, схема без версионирования, частые релизы. Низкая: библиотека, зафиксированная версия, стабильный протокол.
- Оцените ущерб от отказа. Потеря денег, потеря данных, недоступность, косметика.
- Пересечение «высокая вероятность × высокий ущерб» — обязательные интеграционные тесты. Остальное — по остаточному принципу.
Отдельно про стоимость. Считайте не «сколько тестов», а минуты прогона и часы поддержки.
Сервис с 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 (влияет на выручку) Предусловия:
checkout-webзапущен сBILLING_URL, указывающим на тестовый экземпляр.- Пользователь
u-77существует, баланс 0.Шаги:
- Отправить
POST /api/checkoutс телом{"userId": "u-77", "planId": "pro"}.- Дождаться ответа (таймаут 5 с).
- Запросить
GET /api/checkout/{id}.Ожидаемый результат:
- Шаг 1: HTTP 409, тело
{"code": "INSUFFICIENT_FUNDS", "retryable": true}.- Заказ в БД в статусе
payment_failed, полеfailure_reason = 'INSUFFICIENT_FUNDS'.- В топик
ordersНЕ отправлено событиеOrderPaid.- В логах ровно одна запись уровня 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-api2.11.0, БД PostgreSQL 16.4Шаги воспроизведения:
curl -H 'Accept: application/json' https://billing.staging/v1/users/u-42/subscription- Открыть страницу подписки в 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
Хороший баг-репорт на интеграции почти всегда содержит сырой ответ соседа — без него спор «у меня работает» длится днями.
Типичные ошибки
- Подмена прод-СУБД на «похожую» в тестах. SQLite вместо PostgreSQL — зелёные тесты и красный прод. Testcontainers снимают этот компромисс полностью.
postgres:latestв тестах. Однажды соберётся другой мажор, и вы будете чинить не свой код.- Контейнер на каждый тест. 300 тестов × 2 с старта = 10 минут в чистом виде.
- Хардкод портов. Тест работает у вас и падает на раннере, где порт занят.
sleep(3)вместо ожидания условия. Главный источник флаки в асинхронных тестах.- Широкие интеграционные тесты вместо узких. Поднимать восемь сервисов, чтобы проверить парсинг даты, — это E2E, названный интеграцией, со всеми его недостатками.
- Провайдер-стейты, которые молча игнорируют неизвестное состояние. Верификация зелёная, проверок нет.
- Pact там, где хватило бы OpenAPI-валидации. Внедрение стоит недели, поддержка — постоянна.
- Контракты без
can-i-deploy. Файлы есть, защиты нет. - Оценка интеграционного слоя по line coverage. Метрика растёт, дефекты не находятся — ровно тот самообман, о котором говорилось в обзоре курса.
- Отсутствие бюджета времени. Слой без ограничения растёт, пока разработчики не начнут его обходить.
- Тесты, зависящие от порядка выполнения. Работают локально, разваливаются при
-n 4.
Мини-итог
- Интеграционное тестирование проверяет гипотезы о соседях, которые юнит-тесты фиксируют в моках и потому проверить не могут.
- Различайте узкие и широкие интеграционные тесты. Узкие — рабочая лошадка: одна реальная зависимость, секунды на прогон. Широкие — это почти E2E, и относиться к ним надо так же строго.
- Testcontainers убирают старый компромисс «реалистично или быстро»: реальная СУБД той же версии, что в проде, стартует за секунды. Держите один контейнер на сессию, изолируйте тесты транзакцией или truncate, фиксируйте minor-версию образа.
- В асинхронных сценариях проверяйте идемпотентность, порядок и дубликаты — at-least-once доставка делает их обязательными, а не экзотикой.
- Контрактные тесты разрывают комбинаторный взрыв «поднять весь граф сервисов»: каждая
сторона проверяется отдельно против общего контракта. Ценность возникает только вместе
с
can-i-deployв пайплайне. - Начинайте с дешёвых схемных проверок (
buf breaking, OpenAPI-валидация, Schema Registry); Pact подключайте там, где у API много независимых потребителей. - Выбирайте объекты покрытия риск-ориентированно: «вероятность расхождения × ущерб». Считайте покрытие стыков, а не строк.
- Держите бюджет времени и относитесь к флакам как к дефектам. Слой, который дольше пяти минут и падает через раз, будет обойдён — и вы вернётесь туда, откуда начинали.
Источники и что почитать дальше:
- Martin Fowler, Integration Test и Consumer-Driven Contracts
- Документация Testcontainers — гайды по всем языкам
- Pact docs — особенно разделы про provider states и can-i-deploy
- Buf: breaking change detection
- Confluent Schema Registry: compatibility types
- Gerard Meszaros, «xUnit Test Patterns» — главы про Test Doubles и Fresh Fixture
- Toxiproxy (github.com/Shopify/toxiproxy) — для тестов на таймауты и деградацию сети
Что дальше
Мы дошли до границы, за которой начинается пользовательский интерфейс — самый хрупкий и самый дорогой слой автоматизации. Дальше: E2E и UI-тесты: Playwright, Selenium, борьба с хрупкостью.