
• Оглавление
Интеграции ломаются не потому, что разработчики «плохие», а потому что стороны видят контракт по‑разному. Поставщик меняет поле или формат ответа — потребитель падает в проде. Полные интеграционные и end‑to‑end тесты помогают, но они дорогие, медленные и хрупкие.
Consumer‑driven контрактные тесты (далее — CTC) решают проблему на уровне «ожидания → доказательство → верификация»:
Бизнес‑выгода:
Ключевые роли:
Поток:
Важно: контракт не описывает ВСЁ поведение API, а фиксирует то, что реально нужно потребителю. Это делает контракты компактными, стабильными и дешевыми в сопровождении.
Сценарий: сервис «Витрина» (consumer) читает карточку товара у «Каталога» (provider).
.
├─ package.json
├─ jest.config.js
├─ src
│ ├─ provider.js
│ └─ server.js
├─ test
│ ├─ consumer.pact.test.js
│ └─ provider.pact.verify.test.js
└─ pacts/ # сюда будут генерироваться контракты
{
"name": "pact-demo",
"version": "1.0.0",
"private": true,
"scripts": {
"start:provider": "node src/server.js",
"test:consumer": "jest test/consumer.pact.test.js --runInBand",
"test:provider": "jest test/provider.pact.verify.test.js --runInBand",
"pact:publish": "docker run --rm -v $PWD/pacts:/pacts pactfoundation/pact-cli:latest pact-broker publish /pacts --consumer-app-version $(git rev-parse --short HEAD) --branch main --broker-base-url http://localhost:9292"
},
"dependencies": {
"axios": "^1.6.0",
"express": "^4.18.3"
},
"devDependencies": {
"@pact-foundation/pact": "^12.0.0",
"jest": "^29.7.0"
}
}
module.exports = {
testEnvironment: 'node',
verbose: true
};
// src/provider.js
const express = require('express');
const app = express();
app.use(express.json());
let db = new Map();
function reset() {
db = new Map();
}
function seedProduct(product) {
db.set(String(product.id), product);
}
app.get('/products/:id', (req, res) => {
const id = String(req.params.id);
const product = db.get(id);
if (!product) {
return res.status(404).json({ message: 'not_found' });
}
res.json(product);
});
module.exports = { app, reset, seedProduct };
// src/server.js
const { app } = require('./provider');
const port = process.env.PORT || 9000;
app.listen(port, () => console.log(`Provider listening on :${port}`));
// test/consumer.pact.test.js
const path = require('path');
const axios = require('axios');
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const provider = new PactV3({
consumer: 'webshop',
provider: 'catalog',
dir: path.resolve(process.cwd(), 'pacts')
});
describe('Webshop -> Catalog contract', () => {
test('должен получить карточку товара по ID', async () => {
provider
.given('product with ID 42 exists')
.uponReceiving('a request for existing product 42')
.withRequest({
method: 'GET',
path: '/products/42'
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: MatchersV3.like({
id: 42,
name: MatchersV3.string('Кружка «Лого»'),
price: MatchersV3.integer(49900),
currency: MatchersV3.string('RUB')
})
});
await provider.executeTest(async (mockServer) => {
const res = await axios.get(`${mockServer.url}/products/42`);
expect(res.status).toBe(200);
expect(res.data).toEqual(
expect.objectContaining({ id: 42, currency: 'RUB' })
);
});
});
test('должен получить 404 для несуществующего товара', async () => {
provider
.given('product 999 does not exist')
.uponReceiving('a request for a missing product 999')
.withRequest({ method: 'GET', path: '/products/999' })
.willRespondWith({
status: 404,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: MatchersV3.like({ message: 'not_found' })
});
await provider.executeTest(async (mockServer) => {
await expect(axios.get(`${mockServer.url}/products/999`))
.rejects.toHaveProperty('response.status', 404);
});
});
});
Контракт будет сгенерирован в каталоге pacts как файл webshop-catalog.json.
// test/provider.pact.verify.test.js
const path = require('path');
const { Verifier } = require('@pact-foundation/pact');
const { app, reset, seedProduct } = require('../src/provider');
let server;
beforeAll(async () => {
await new Promise((resolve) => {
server = app.listen(9000, () => resolve());
});
});
afterAll(async () => {
await new Promise((resolve) => server.close(() => resolve()));
});
/**
* Локальная верификация по файлу контракта.
* Для работы с Broker см. раздел ниже.
*/
test('верификация контрактов потребителей', async () => {
reset();
const verifier = new Verifier({
providerBaseUrl: 'http://localhost:9000',
pactUrls: [path.resolve(process.cwd(), 'pacts/webshop-catalog.json')],
stateHandlers: {
'product with ID 42 exists': async () => {
seedProduct({ id: 42, name: 'Кружка «Лого»', price: 49900, currency: 'RUB' });
},
'product 999 does not exist': async () => {
reset();
}
}
});
const output = await verifier.verifyProvider();
console.log(output);
});
Хранить контракты в Git — можно, но вы теряете историю проверок и совместимость версий. Pact Broker решает это «из коробки».
# docker-compose.yml
version: '3.8'
services:
db:
image: postgres:15
environment:
POSTGRES_USER: pactbroker
POSTGRES_PASSWORD: pactbroker
POSTGRES_DB: pactbroker
healthcheck:
test: ["CMD", "pg_isready", "-U", "pactbroker"]
interval: 5s
timeout: 5s
retries: 10
broker:
image: pactfoundation/pact-broker:latest
ports:
- "9292:9292"
environment:
PACT_BROKER_DATABASE_URL: postgres://pactbroker:pactbroker@db:5432/pactbroker
PACT_BROKER_LOG_LEVEL: INFO
depends_on:
db:
condition: service_healthy
Запуск:
docker compose up -d
Публикация контракта из проекта потребителя:
npm run test:consumer && npm run pact:publish
Верификация у поставщика через Broker (публикуем статусы):
// пример verifier с Broker (замените в provider.pact.verify.test.js)
const verifier = new Verifier({
providerBaseUrl: 'http://localhost:9000',
provider: 'catalog',
pactBrokerUrl: 'http://localhost:9292',
publishVerificationResult: true,
providerVersion: process.env.GIT_SHA || 'dev-local',
providerVersionBranch: process.env.GIT_BRANCH || 'local',
consumerVersionSelectors: [
{ branch: 'main', latest: true }
],
stateHandlers: {
'product with ID 42 exists': async () => {
seedProduct({ id: 42, name: 'Кружка «Лого»', price: 49900, currency: 'RUB' });
},
'product 999 does not exist': async () => {
reset();
}
}
});
Два джоба: один генерирует и публикует контракт (consumer), второй поднимает поставщика и верифицирует.
# .github/workflows/contracts.yml
name: Contracts
on:
push:
branches: [ "main" ]
jobs:
consumer:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: docker compose -f docker-compose.yml up -d broker db
- run: npm run test:consumer
- run: npm run pact:publish
provider:
runs-on: ubuntu-latest
needs: consumer
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: docker compose -f docker-compose.yml up -d broker db
- name: Верификация от Broker
env:
GIT_SHA: ${{ github.sha }}
GIT_BRANCH: ${{ github.ref_name }}
run: |
node -e "require('child_process').spawnSync('node',['-e','console.log(\'start\')'])"
npm run test:provider
Под реальный проект имеет смысл разделить репозитории/пайплайны потребителя и поставщика и добавить правила: не релизим, если в Broker есть неподтвержденные (pending) контракты.
Открытый вопрос: если у нас есть OpenAPI, зачем Pact? Ответ: у них разные задачи.
Практика:
Мини‑пример OpenAPI под наш endpoint:
openapi: 3.0.3
info:
title: Catalog API
version: 1.0.0
paths:
/products/{id}:
get:
parameters:
- in: path
name: id
required: true
schema: { type: integer }
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
required: [id, name, price, currency]
properties:
id: { type: integer }
name: { type: string }
price: { type: integer, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3 }
'404':
description: Not Found
content:
application/json:
schema:
type: object
required: [message]
properties:
message: { type: string, enum: [not_found] }
Совет: добавьте в поставщика валидацию запросов/ответов по OpenAPI (например, express-openapi-validator). Так вы гарантируете, что реализованный API соответствует спецификации, а Pact гарантирует, что изменения не ломают потребителей.
Опираясь на опыт внедрения у продуктовых команд:
Итог: consumer‑driven контрактные тесты не пытаются заменить интеграционные испытания, а делают их адресными и предсказуемыми. Это быстрый и недорогой способ стабилизировать интеграции, ускорить релизы и убрать «неожиданные» поломки на проде — там, где они особенно дороги.