Недавно я работала над сложным проектом, в котором фронтенд был готов задолго до того, как появились бэкенд-API. У нас были макеты, компоненты и мок-данные. Но не было никакой гарантии, что, когда настоящий API наконец появится, он будет хоть сколько-нибудь похож на то, под что мы писали код.

Это очень нервирующая ситуация.

Бэкенд-команда, работавшая над тем же проектом, немного опережала нас. Менялись названия полей. Менялся формат ответов. И о каждом таком изменении кто-то из бэкенд-команды должен был не забыть сообщить нам. Иногда это происходило. Иногда мы узнавали об изменениях, когда что-то выходило из строя на стадии тестирования.

Тогда я и начала изучать тестирование контрактов, в частности, Pact.

Что вообще такое контракт?

Когда фронтенд вызывает API, возникает негласное соглашение. Фронтенд ожидает определенные поля в определенном формате. Бэкенд обещает их возвращать. Но это соглашение обычно существует только на странице Confluence, в ветке Slack или в чьей-то памяти.

Контракт делает это соглашение явным и проверяемым. Если одна из сторон его нарушает, вы узнаете об этом сразу, а не на стадии тестирования.

Почему бы не написать сквозные тесты?

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

Тесты контрактов работают иначе. Они запускаются на локальном мок-сервере, который Pact создает для вас. Не требуется ни бэкенд, ни общая среда. Только быстрые и надежные тесты, показывающие, соблюдается ли соглашение.

Вы тестируете не поведение, а само соглашение: возвращает ли бэкенд то, что ожидает фронтенд?

Как работает Pact

Pact следует подходу, ориентированному на потребителя сервиса. Это идеально подходило нам, поскольку мы сначала создавали фронтенд.

  1. Потребитель (фронтенд) пишет тесты, точно описывающие, что ему нужно от API.
  2. Эти тесты генерируют JSON-файл контракта — «pact» («соглашение», «контракт»).
  3. Провайдер (бэкенд) запускает этот контракт в процессе реализации, чтобы проверить, может ли он его выполнить.
  4. 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.

Это компромисс, на который я готова пойти в любой момент.


Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Shelcia David: I Built the Frontend First. Here’s How I Made Sure the Backend Team Didn’t Break It

Предыдущая статьяJVM — самый недооцененный инженерный шедевр в ПО-индустрии