
Интеграционные баги между сервисами — одни из самых дорогих. Они часто всплывают поздно: на стейджинге или уже на продакшене, когда изменения из нескольких команд наконец встречаются. Контрактные тесты переносят проверку совместимости на этап коммитов: команды согласуют «контракт» (что, как и в каком формате сервисы передают друг другу) до релиза.
Бизнес‑эффект:
CDC (Consumer‑Driven Contracts) — «контракты, управляемые потребителем». Потребитель сервиса описывает свои ожидания к ответам провайдера и публикует их в виде контракта. Провайдер регулярно проверяет, что реализация этим ожиданиям соответствует.
Как это работает на практике:
Чем это отличается от интеграционных стендов:
Ниже — рабочий базовый пример на Node.js с Pact Foundation. Потребитель — клиент, который запрашивает профиль пользователя. Провайдер — API, отдающий этот профиль.
version: "3.9"
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: pact
POSTGRES_PASSWORD: pact
POSTGRES_DB: pact
ports:
- "5433:5432"
pact-broker:
image: pactfoundation/pact-broker:2.122.0.0
depends_on:
- postgres
ports:
- "9292:9292"
environment:
PACT_BROKER_DATABASE_URL: postgres://pact:pact@postgres:5432/pact
PACT_BROKER_LOG_LEVEL: INFO
PACT_BROKER_PUBLIC_HEARTBEAT: "true"
Сохраните как docker-compose.yml и запустите: docker compose up -d.
Структура:
{
"name": "consumer",
"version": "1.0.0",
"type": "module",
"scripts": {
"test": "node --test",
"pact:publish": "pact-broker publish pacts --consumer-app-version=$(git rev-parse --short HEAD) --broker-base-url=http://localhost:9292"
},
"dependencies": {
"node-fetch": "3.3.2"
},
"devDependencies": {
"@pact-foundation/pact": "12.3.0",
"@pact-foundation/pact-core": "13.6.0"
}
}
// consumer/src/client.js
import fetch from 'node-fetch';
export async function getUser(baseUrl, id) {
const res = await fetch(`${baseUrl}/users/${id}`, {
headers: { 'Accept': 'application/json' }
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
// Потребителю важно: число id, обязательные email и name
return {
id: Number(data.id),
email: data.email,
name: data.name,
// Остальные поля не критичны — игнорируем
};
}
// consumer/test/consumer.pact.test.js
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { getUser } from '../src/client.js';
import assert from 'node:assert/strict';
const { like, regex, integer } = MatchersV3;
const pact = new PactV3({
consumer: 'web-frontend',
provider: 'user-service'
});
await pact.addInteraction()
.given('User 42 exists') // состояние провайдера
.uponReceiving('get user by id')
.withRequest({
method: 'GET',
path: regex({ generate: '/users/42', matcher: '/users/\\d+' }),
headers: { 'Accept': 'application/json' }
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: {
id: integer(42),
email: regex({ generate: 'user42@example.com', matcher: '.+@.+\\..+' }),
name: like('Ada Lovelace'),
// Допустимы дополнительные поля — провайдер может их добавлять
}
});
await pact.executeTest(async (mock) => {
const result = await getUser(mock.url, 42);
assert.equal(result.id, 42);
assert.match(result.email, /@/);
});
Тест запустит мок‑провайдера Pact, проверит ожидания и сгенерирует контракт pacts/web-frontend-user-service.json.
Публикация контракта в Broker:
npm --prefix consumer test
npm --prefix consumer run pact:publish
Структура:
{
"name": "provider",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/server.js",
"verify": "node test/verify.pact.test.js"
},
"dependencies": {
"express": "4.19.2"
},
"devDependencies": {
"@pact-foundation/pact": "12.3.0"
}
}
// provider/src/server.js
import express from 'express';
export function createApp({ repo }) {
const app = express();
app.get('/users/:id', (req, res) => {
const user = repo.getUser(Number(req.params.id));
if (!user) return res.status(404).json({ error: 'not_found' });
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.json(user);
});
return app;
}
if (process.env.NODE_ENV !== 'test') {
// Пример запуска в одиночку
const app = createApp({
repo: {
getUser: (id) => ({ id, email: `user${id}@example.com`, name: 'Ada Lovelace' })
}
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`user-service on ${port}`));
}
// provider/test/verify.pact.test.js
import { Verifier } from '@pact-foundation/pact';
import { createApp } from '../src/server.js';
// Фиктивное хранилище, подстраиваемое под состояния
const repo = {
getUser: (id) => null
};
// Сервер для проверки (порт выбираем заранее)
const port = 4001;
const app = createApp({ repo });
const server = app.listen(port);
async function run() {
const verifier = new Verifier({
providerBaseUrl: `http://localhost:${port}`,
provider: 'user-service',
providerVersion: process.env.GIT_COMMIT || 'dev',
pactBrokerUrl: 'http://localhost:9292',
// Проверяем только контракты потребителя web-frontend
consumers: ['web-frontend'],
stateHandlers: {
'User 42 exists': async () => {
repo.getUser = (id) => id === 42 ? ({ id, email: 'user42@example.com', name: 'Ada Lovelace' }) : null;
}
}
});
try {
const output = await verifier.verifyContract();
console.log('Pact verification complete:', output);
process.exit(0);
} catch (e) {
console.error('Pact verification failed:', e);
process.exit(1);
} finally {
server.close();
}
}
run();
Если провайдер не соответствует ожиданиям, верификация упадёт с понятным сообщением: что именно не совпало и в каком поле.
Типичный пайплайн:
feat-x) → опционально запускает can-i-deploy, чтобы понять, можно ли выкатывать потребителя с текущими провайдерами.can-i-deploy для своего релиза.Примеры GitHub Actions:
# .github/workflows/consumer.yml
name: consumer
on: [push]
jobs:
test-and-publish:
runs-on: ubuntu-latest
services:
broker:
image: pactfoundation/pact-broker:2.122.0.0
ports: ["9292:9292"]
env:
PACT_BROKER_DATABASE_URL: sqlite:////tmp/pact.db
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci --prefix consumer
- run: npm --prefix consumer test
- run: npm --prefix consumer run pact:publish
# .github/workflows/provider.yml
name: provider
on: [push]
jobs:
verify:
runs-on: ubuntu-latest
services:
broker:
image: pactfoundation/pact-broker:2.122.0.0
ports: ["9292:9292"]
env:
PACT_BROKER_DATABASE_URL: sqlite:////tmp/pact.db
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci --prefix provider
- run: npm --prefix provider run verify
Для продакшен‑цепочки добавьте pact-broker can-i-deploy с тегами окружений (например, --to-environment=prod) и блокируйте релиз при несовместимости. Так вы исключите «ломающие» выкаты ещё до попадания в прод.
Как безопасно менять API:
Pact/Broker помогает с матрицей совместимости: видно, какие версии потребителей прошли проверку против каких версий провайдера и можно ли выкатывать.
Сами по себе контракты — это примеры. Сделайте их репрезентативными:
Контрактные тесты — это «страховка» между командами. Они дешевле стендов, понятнее логов из продакшена и позволяют выпускать чаще, не боясь сломать соседей. Начните с малого пилота — и через месяц вы уже не захотите возвращаться к прежним «интеграционным лотереям».