
Недавно я работала над сложным проектом, в котором фронтенд был готов задолго до того, как появились бэкенд-API. У нас были макеты, компоненты и мок-данные. Но не было никакой гарантии, что, когда настоящий API наконец появится, он будет хоть сколько-нибудь похож на то, под что мы писали код.
Это очень нервирующая ситуация.
Бэкенд-команда, работавшая над тем же проектом, немного опережала нас. Менялись названия полей. Менялся формат ответов. И о каждом таком изменении кто-то из бэкенд-команды должен был не забыть сообщить нам. Иногда это происходило. Иногда мы узнавали об изменениях, когда что-то выходило из строя на стадии тестирования.
Тогда я и начала изучать тестирование контрактов, в частности, Pact.
Что вообще такое контракт?
Когда фронтенд вызывает API, возникает негласное соглашение. Фронтенд ожидает определенные поля в определенном формате. Бэкенд обещает их возвращать. Но это соглашение обычно существует только на странице Confluence, в ветке Slack или в чьей-то памяти.
Контракт делает это соглашение явным и проверяемым. Если одна из сторон его нарушает, вы узнаете об этом сразу, а не на стадии тестирования.
Почему бы не написать сквозные тесты?
Это была первая мысль, пришедшая мне в голову. Но для сквозных тестов нужен работающий бэкенд, реальные данные, реальная среда. Они медленные, неустойчивые и полностью выходят из строя, когда бэкенд еще не готов — и именно в такой ситуации я и оказалась.
Тесты контрактов работают иначе. Они запускаются на локальном мок-сервере, который Pact создает для вас. Не требуется ни бэкенд, ни общая среда. Только быстрые и надежные тесты, показывающие, соблюдается ли соглашение.
Вы тестируете не поведение, а само соглашение: возвращает ли бэкенд то, что ожидает фронтенд?
Как работает Pact
Pact следует подходу, ориентированному на потребителя сервиса. Это идеально подходило нам, поскольку мы сначала создавали фронтенд.
- Потребитель (фронтенд) пишет тесты, точно описывающие, что ему нужно от API.
- Эти тесты генерируют JSON-файл контракта — «pact» («соглашение», «контракт»).
- Провайдер (бэкенд) запускает этот контракт в процессе реализации, чтобы проверить, может ли он его выполнить.
- CI следит за честностью обеих сторон, улавливая любые отклонения, прежде чем они попадут в продакшен-среду.
Файл контракта становится источником истины. Он хранится в git, имеет версии и передается между командами.
Настройка с нуля
Ниже представлено полное руководство. Мы используем Pact v15, Mocha в качестве исполнителя тестов и Chai для утверждений. Все на TypeScript.
Структура создаваемых нами папок:
your-ui-project/
├── pact/
│ ├── .mocharc.json
│ ├── setup-chai.ts
│ ├── tsconfig.json
│ ├── contracts/ <- generated contract JSONs live here
│ └── src/
│ └── consumer/
│ └── your-feature/
│ ├── fixtures
│ │ └── create.ts
│ └── create.spec.ts
└── package.json
Шаг 1: Установка зависимостей
Добавьте следующие строки в файл package.json:
{
"devDependencies": {
"@pact-foundation/pact": "^15.0.1",
"@pact-foundation/pact-cli": "^15.0.1",
"@types/chai": "^5.2.2",
"@types/chai-as-promised": "^8.0.2",
"@types/mocha": "^10.0.10",
"chai": "^5.2.0",
"chai-as-promised": "^8.0.1",
"mocha": "^11.2.2",
"tsx": "^4.19.3",
"typescript": "^5.5.3" },
"scripts": {
"test:pact:consumer": "cd pact && mocha \"./src/consumer/**/*.spec.ts\""
}
}
Шаг 2: Настройка Mocha
Создайте файл pact/.mocharc.json. Он указывает Mocha, как находить и запускать тестовые TypeScript-файлы:
{
"$schema": "https://json.schemastore.org/mocharc",
"color": true,
"exit": true,
"file": "./setup-chai.ts",
"node-option": ["import=tsx"],
"extension": ["ts"],
"recursive": true,
"retries": 1,
"timeout": 5000
}
Шаг 3: Настройка Chai
Создайте файл pact/setup-chai.ts. Он запускается перед любым тестом и регистрирует плагин chai-as-promised, чтобы писать асинхронные утверждения без лишних хлопот:
import * as chai from 'chai'
import chaiAsPromised from 'chai-as-promised'
chai.use(chaiAsPromised)
Шаг 4: Создание файла с константами
Создайте файл pact/src/constants.ts. Имена потребителей и поставщиков, общие заголовки и место, где записываются контракты. Централизованное хранение этих данных избавляет от множества повторений в дальнейшем.
import path from 'path'
import { string } from '@pact-foundation/pact/src/v3/matchers'
export const ISO8601_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss.sssZ'
export enum Provider {
MyBackend = 'my-backend',
}
export enum Consumer {
MyApp = 'my-app',
}
export const AUTHORIZATION_HEADER = 'Authorization'
// Tells Pact the header must exist as a string,
// without caring about the actual token value
export const DEFAULT_REQUEST_HEADERS = {
[AUTHORIZATION_HEADER]: string(),
'Content-Type': 'application/json',
}
export const CONTRACTS_DIR = path.resolve(process.cwd(), 'contracts')
Что мне здесь понравилось: использование string() для заголовка аутентификации означает, что Pact проверяет наличие заголовка, не обращая внимания на фактическое значение токена. Вы не вписываете реальные учетные данные в свои тесты.
Шаг 5: Добавление вспомогательных утилит
Создайте файл pact/src/helpers.ts. Небольшой хелпер consumerName() позволяет сохранять описательные и согласованные имена файлов контрактов по мере роста количества конечных точек.
import path from 'path'
import { CONTRACTS_DIR } from './constants'
// e.g. consumerName('my-app', 'user', 'create') returns "my-app-user-create"
export const consumerName = (
consumer: string,
feature: string,
scope: string
) => `${consumer}-${feature}-${scope}`
export const getPactFile = (consumer: string, provider: string): string =>
path.join(CONTRACTS_DIR, `${consumer}-${provider}.json`)
Шаг 6: Определение типов TypeScript
Создайте файл pact/src/types.ts. Опишите все типы. Это позволит выявить несоответствия между вашими фикстурами и фактическими формами API еще до запуска Pact.
export interface GraphQLResponse<T> {
data: T
}
export interface CreateUserInput {
name: string
}
export interface CreateUserData {
createUser: {
uuid: string
name: string
createdAt: string
updatedAt: string
}
}
Шаг 7: Написание фикстур
Именно здесь происходит большая часть специфического для Pact мышления. Фикстуры определяют тестовые данные и матчеры. Матчеры — ключевая идея в Pact: вместо того, чтобы указать «поле name должно равняться John Doe», вы пишете «поле name должно быть строкой». Контракт остается действительным даже при изменении тестовых данных.
import { like, uuid, datetime, string } from '@pact-foundation/pact/src/v3/matchers'
import { ISO8601_DATETIME_FORMAT } from '../../constants'
import { CreateUserInput } from '../../types'
export const CREATE_USER_MUTATION = `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
uuid
name
createdAt
updatedAt
__typename
}
}
`
export const createUserInput: CreateUserInput = { name: 'John Doe' }
export const createUserRequestBody = {
operationName: 'CreateUser',
query: CREATE_USER_MUTATION,
variables: { input: createUserInput },
}
export const createUserRequestBodyMatcher = {
operationName: like('CreateUser'),
query: like(CREATE_USER_MUTATION),
variables: { input: like({ name: 'John Doe' }) },
}
export const createUserSuccessResponseMatcher = {
data: {
createUser: {
uuid: uuid('082b6218-db3d-4090-85a4-6c1d4178fc6d'),
name: string('John Doe'),
createdAt: datetime(ISO8601_DATETIME_FORMAT, '2025-05-20T18:25:31.02656Z'),
updatedAt: datetime(ISO8601_DATETIME_FORMAT, '2025-05-20T18:25:31.02656Z'),
},
},
}
Три матчера, к которым я чаще всего обращалась в этом проекте:
like(value)проверяет тип и структуру, а не точное значение;uuid(example)проверяет, что поле является корректно отформатированным UUID;datetime(format, example)проверяет, соответствует ли поле временной метке в формате ISO 8601.
Примечание: если вы используете Apollo, убедитесь, что __typename присутствует в вашей мутации. Apollo автоматически добавляет его в каждый запрос, и Pact сообщит о несоответствии, если он отсутствует в вашей фикстуре.
Шаг 8: Написание теста
Теперь файл спецификации. Pact запускает тестовый сервер, ваш реальный API-клиент вызывает его, и если все совпадает, JSON-файл контракта записывается на диск.
import { PactV3 } from '@pact-foundation/pact'
import { expect } from 'chai'
import { Consumer, Provider, CONTRACTS_DIR, DEFAULT_REQUEST_HEADERS } from '../../constants'
import { consumerName } from '../../helpers'
import {
createUserRequestBody,
createUserRequestBodyMatcher,
createUserSuccessResponseMatcher,
} from './fixtures/create'
const provider = new PactV3({
dir: CONTRACTS_DIR,
consumer: consumerName(Consumer.MyApp, 'user', 'create'),
provider: Provider.MyBackend,
})
const createUser = async (baseUrl: string, token: string) => {
const response = await fetch(`${baseUrl}/graphql`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(createUserRequestBody),
})
return response.json()
}
describe('My App - User - Create', () => {
it('returns 200 and user data when creation succeeds', async () => {
provider
.uponReceiving('a GraphQL request to create a user')
.withRequest({
method: 'POST',
path: '/graphql',
headers: DEFAULT_REQUEST_HEADERS,
body: createUserRequestBodyMatcher,
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: createUserSuccessResponseMatcher,
})
return provider.executeTest(async (mockServer) => {
const result = await createUser(mockServer.url, 'test-token')
expect(result).to.have.property('data')
expect(result.data).to.have.property('createUser')
expect(result.data.createUser).to.have.property('uuid')
expect(result.data.createUser).to.have.property('name')
})
})
})
Шаг 9: Запуск теста
# Install deps if you haven't yet
yarn install
# Run from your project root
yarn test:pact:consumer
Если тест пройдет успешно, в папке pact/contracts/my-app-user-create-my-backend.json появится файл контракта. Именно этот файл вы передаете бэкенд-команде. Она запускает его в своей реализации для проверки совместимости — при этом не требуется общая среда и не нужно ждать развертывания.
Шаг 10 (заключительный): CI
Зафиксируйте (закоммитьте) файлы контрактов в формате JSON в git и добавьте шаг CI, который будет перегенерировать их при каждом запуске и сравнивать с зафиксированными (закоммиченными) версиями. Если они не совпадают, CI завершится с ошибкой.
Это означает: если кто-то из бэкенд-команды изменит имя поля, CI обнаружит это, прежде чем изменения попадут в тестовую среду. Если же вы намеренно обновляете контракт, делаете ревью diff, коммитите его, и все знают, что соглашение изменилось.
Вот как это выглядит в GitLab CI:
.ui-pact-consumer:
extends: .ui-base
stage: Test
script:
- cd ui/$PROJECT
- |
if grep -q "test:pact:consumer" package.json; then
yarn test:pact:consumer
else
echo "Pact consumer tests not configured for $PROJECT"
exit 0
fi
needs:
- job: mr:run-pipeline
optional: true
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
- ui/${PROJECT}/pact/**/*
- ui/${PROJECT}/src/graphql/**/*
- ui/${PROJECT}/package.json
- ci/base/*
- ci/test/${PROJECT}.gitlab-ci.yml
pact-test:consumer:my-app:
extends: .ui-pact-consumer
variables:
PROJECT: my-app
needs:
- job: mr:run-pipeline
optional: true
Именно этот момент оказался наиболее полезным для нас. Файл контракта в git стал тем, на что обе команды могли указывать. Больше не было отговорок типа «я думал, мы согласовали имя этого поля».
Советы, которые я дала бы себе раньше:
- Используйте
like()везде. Точное сопоставление значений приводит к постоянным нарушениям контрактов без веских на то причин. - Один контракт должен быть для одной функции или операции. Это может показаться увеличением количества файлов, но значительно упрощает отладку.
- Тесты контрактов не заменяют модульные или интеграционные тесты. Они используются с вполне конкретной целью — устранения разногласий между фронтендом и бэкендом.
Если вы находитесь в ситуации, когда фронтенд и бэкенд движутся с разной скоростью и вы переживаете, что они рассинхронизируются, Pact действительно стоит затраченного на настройку времени. Он дал нашей команде общий язык для API-соглашений, и теперь, вместо того чтобы выяснять «Ты изменил структуру ответа?» в продакшн-среде, мы видим красные лампочки в CI.
Это компромисс, на который я готова пойти в любой момент.
Читайте также:
- О чем умалчивают гайды по проектированию масштабируемых бэкендов
- Полное руководство по тестированию контрактов с помощью PACT и Go
- Как использовать Claude Code в разработке бэкенда?
Читайте нас в Telegram, VK и Дзен
Перевод статьи Shelcia David: I Built the Frontend First. Here’s How I Made Sure the Backend Team Didn’t Break It





