by thetestingacademy
Consumer-driven contract testing with Pact and Pact Broker
npx @qaskills/cli add contract-testing-pactAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
You are an expert QA engineer specializing in consumer-driven contract testing with Pact. When the user asks you to write, review, or debug Pact contract tests, follow these detailed instructions.
Consumer Side Provider Side
┌─────────────┐ ┌─────────────┐
│ Consumer │ │ Provider │
│ Test │ │ Test │
│ │ │ │
│ 1. Define │ Pact File │ 3. Verify │
│ expected │ ─────────────> │ against │
│ behavior │ │ running │
│ │ │ service │
│ 2. Generate │ │ │
│ Pact │ │ 4. Publish │
│ file │ │ results │
└─────────────┘ └─────────────┘
│ │
│ ┌───────────────┐ │
└──────>│ Pact Broker │<──────┘
│ │
│ Stores pacts │
│ Tracks │
│ verification │
│ can-i-deploy │
└───────────────┘
consumer-service/
src/
clients/
user-api-client.ts
product-api-client.ts
models/
user.model.ts
tests/
contract/
user-api.consumer.pact.spec.ts
product-api.consumer.pact.spec.ts
pacts/ <-- Generated Pact files
consumer-user-service.json
jest.config.ts
package.json
provider-service/
src/
routes/
users.routes.ts
services/
user.service.ts
tests/
contract/
user-api.provider.pact.spec.ts
utils/
provider-states.ts
jest.config.ts
package.json
npm install --save-dev @pact-foundation/pact
// src/clients/user-api-client.ts
import axios, { AxiosInstance } from 'axios';
export interface User {
id: string;
email: string;
name: string;
role: string;
}
export interface CreateUserRequest {
email: string;
name: string;
password: string;
}
export class UserApiClient {
private http: AxiosInstance;
constructor(baseUrl: string) {
this.http = axios.create({ baseUrl });
}
async getUser(id: string): Promise<User> {
const response = await this.http.get(`/api/users/${id}`);
return response.data;
}
async createUser(data: CreateUserRequest): Promise<User> {
const response = await this.http.post('/api/users', data);
return response.data;
}
async listUsers(page: number = 1): Promise<{ data: User[]; total: number }> {
const response = await this.http.get(`/api/users?page=${page}`);
return response.data;
}
async deleteUser(id: string): Promise<void> {
await this.http.delete(`/api/users/${id}`);
}
}
// tests/contract/user-api.consumer.pact.spec.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
import { UserApiClient } from '../../src/clients/user-api-client';
const { like, eachLike, string, uuid, integer, regex } = MatchersV3;
const provider = new PactV3({
consumer: 'frontend-app',
provider: 'user-service',
dir: path.resolve(process.cwd(), 'tests/pacts'),
logLevel: 'warn',
});
describe('User API Consumer Contract', () => {
describe('GET /api/users/:id', () => {
it('should return a user when the user exists', async () => {
// Arrange: Define the expected interaction
provider
.given('a user with ID user-123 exists')
.uponReceiving('a request to get a user by ID')
.withRequest({
method: 'GET',
path: '/api/users/user-123',
headers: {
Accept: 'application/json',
},
})
.willRespondWith({
status: 200,
headers: {
'Content-Type': 'application/json',
},
body: {
id: like('user-123'),
email: regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'user@example.com'),
name: string('John Doe'),
role: regex(/^(admin|user|viewer)$/, 'user'),
},
});
// Act & Assert: Execute against the mock provider
await provider.executeTest(async (mockServer) => {
const client = new UserApiClient(mockServer.url);
const user = await client.getUser('user-123');
expect(user.id).toBe('user-123');
expect(user.email).toBeDefined();
expect(user.name).toBeDefined();
expect(user.role).toBeDefined();
});
});
it('should return 404 when the user does not exist', async () => {
provider
.given('no user with ID nonexistent exists')
.uponReceiving('a request to get a non-existent user')
.withRequest({
method: 'GET',
path: '/api/users/nonexistent',
headers: {
Accept: 'application/json',
},
})
.willRespondWith({
status: 404,
headers: {
'Content-Type': 'application/json',
},
body: {
message: string('User not found'),
statusCode: integer(404),
},
});
await provider.executeTest(async (mockServer) => {
const client = new UserApiClient(mockServer.url);
await expect(client.getUser('nonexistent')).rejects.toThrow();
});
});
});
describe('POST /api/users', () => {
it('should create a new user', async () => {
const requestBody = {
email: 'new@example.com',
name: 'New User',
password: 'SecurePass123!',
};
provider
.given('the email new@example.com is not taken')
.uponReceiving('a request to create a new user')
.withRequest({
method: 'POST',
path: '/api/users',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: requestBody,
})
.willRespondWith({
status: 201,
headers: {
'Content-Type': 'application/json',
},
body: {
id: uuid('550e8400-e29b-41d4-a716-446655440000'),
email: string('new@example.com'),
name: string('New User'),
role: string('user'),
},
});
await provider.executeTest(async (mockServer) => {
const client = new UserApiClient(mockServer.url);
const user = await client.createUser(requestBody);
expect(user.id).toBeDefined();
expect(user.email).toBe('new@example.com');
expect(user.name).toBe('New User');
});
});
});
describe('GET /api/users', () => {
it('should return a paginated list of users', async () => {
provider
.given('there are users in the system')
.uponReceiving('a request to list users')
.withRequest({
method: 'GET',
path: '/api/users',
query: { page: '1' },
headers: {
Accept: 'application/json',
},
})
.willRespondWith({
status: 200,
headers: {
'Content-Type': 'application/json',
},
body: {
data: eachLike({
id: uuid(),
email: string('user@example.com'),
name: string('Test User'),
role: string('user'),
}),
total: integer(10),
},
});
await provider.executeTest(async (mockServer) => {
const client = new UserApiClient(mockServer.url);
const result = await client.listUsers(1);
expect(result.data.length).toBeGreaterThan(0);
expect(result.total).toBeGreaterThanOrEqual(result.data.length);
});
});
});
});
// tests/contract/user-api.provider.pact.spec.ts
import { Verifier } from '@pact-foundation/pact';
import path from 'path';
import { app } from '../../src/app'; // Your Express/Fastify app
describe('User Service Provider Contract Verification', () => {
let server: any;
beforeAll(async () => {
server = app.listen(3001);
});
afterAll(async () => {
server.close();
});
it('should validate the expectations of the frontend-app consumer', async () => {
const verifier = new Verifier({
providerBaseUrl: 'http://localhost:3001',
provider: 'user-service',
// Option 1: From local pact files
pactUrls: [
path.resolve(process.cwd(), '../consumer-service/tests/pacts/frontend-app-user-service.json'),
],
// Option 2: From Pact Broker
// pactBrokerUrl: 'https://your-broker.pactflow.io',
// pactBrokerToken: process.env.PACT_BROKER_TOKEN,
// consumerVersionSelectors: [
// { mainBranch: true },
// { deployedOrReleased: true },
// ],
// Provider states setup
stateHandlers: {
'a user with ID user-123 exists': async () => {
// Seed the database with test data
await db.users.create({
id: 'user-123',
email: 'user@example.com',
name: 'John Doe',
role: 'user',
});
return { description: 'User user-123 created' };
},
'no user with ID nonexistent exists': async () => {
// Ensure the user does not exist
await db.users.deleteMany({ id: 'nonexistent' });
return { description: 'Ensured user does not exist' };
},
'the email new@example.com is not taken': async () => {
await db.users.deleteMany({ email: 'new@example.com' });
return { description: 'Email new@example.com is available' };
},
'there are users in the system': async () => {
await db.users.createMany({
data: [
{ id: 'user-1', email: 'u1@example.com', name: 'User 1', role: 'user' },
{ id: 'user-2', email: 'u2@example.com', name: 'User 2', role: 'admin' },
],
});
return { description: 'Seeded 2 users' };
},
},
// Publish verification results
publishVerificationResult: process.env.CI === 'true',
providerVersion: process.env.GIT_COMMIT || '1.0.0',
providerVersionBranch: process.env.GIT_BRANCH || 'main',
// Logging
logLevel: 'warn',
});
await verifier.verifyProvider();
});
});
package com.example.consumer;
import au.com.dius.pact.consumer.dsl.PactDslJsonBody;
import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
import au.com.dius.pact.consumer.junit5.PactConsumerTestExt;
import au.com.dius.pact.consumer.junit5.PactTestFor;
import au.com.dius.pact.consumer.MockServer;
import au.com.dius.pact.core.model.V4Pact;
import au.com.dius.pact.core.model.annotations.Pact;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "user-service")
public class UserApiConsumerPactTest {
@Pact(consumer = "frontend-app")
public V4Pact getUserByIdPact(PactDslWithProvider builder) {
return builder
.given("a user with ID user-123 exists")
.uponReceiving("a request to get user by ID")
.path("/api/users/user-123")
.method("GET")
.headers("Accept", "application/json")
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
.body(new PactDslJsonBody()
.stringType("id", "user-123")
.stringMatcher("email", "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$", "user@example.com")
.stringType("name", "John Doe")
.stringMatcher("role", "^(admin|user|viewer)$", "user")
)
.toPact(V4Pact.class);
}
@Test
@PactTestFor(pactMethod = "getUserByIdPact")
void testGetUserById(MockServer mockServer) {
UserApiClient client = new UserApiClient(mockServer.getUrl());
User user = client.getUser("user-123");
assertThat(user.getId()).isEqualTo("user-123");
assertThat(user.getEmail()).isNotEmpty();
assertThat(user.getName()).isNotEmpty();
}
}
package com.example.provider;
import au.com.dius.pact.provider.junit5.PactVerificationContext;
import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider;
import au.com.dius.pact.provider.junitsupport.*;
import au.com.dius.pact.provider.junitsupport.loader.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Provider("user-service")
@PactBroker(
url = "${PACT_BROKER_URL}",
authentication = @PactBrokerAuth(token = "${PACT_BROKER_TOKEN}")
)
public class UserApiProviderPactTest {
@LocalServerPort
private int port;
@BeforeEach
void setUp(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyPact(PactVerificationContext context) {
context.verifyInteraction();
}
@State("a user with ID user-123 exists")
void userExists() {
userRepository.save(new User("user-123", "user@example.com", "John Doe", "user"));
}
@State("no user with ID nonexistent exists")
void userDoesNotExist() {
userRepository.deleteById("nonexistent");
}
@State("the email new@example.com is not taken")
void emailAvailable() {
userRepository.deleteByEmail("new@example.com");
}
}
# Using Pact CLI
pact-broker publish ./tests/pacts \
--consumer-app-version=$(git rev-parse --short HEAD) \
--branch=$(git rev-parse --abbrev-ref HEAD) \
--broker-base-url=https://your-broker.pactflow.io \
--broker-token=${PACT_BROKER_TOKEN}
# Check if consumer can deploy
pact-broker can-i-deploy \
--pacticipant=frontend-app \
--version=$(git rev-parse --short HEAD) \
--to-environment=production \
--broker-base-url=https://your-broker.pactflow.io \
--broker-token=${PACT_BROKER_TOKEN}
# Check if provider can deploy
pact-broker can-i-deploy \
--pacticipant=user-service \
--version=$(git rev-parse --short HEAD) \
--to-environment=production \
--broker-base-url=https://your-broker.pactflow.io \
--broker-token=${PACT_BROKER_TOKEN}
# After successful deployment
pact-broker record-deployment \
--pacticipant=user-service \
--version=$(git rev-parse --short HEAD) \
--environment=production \
--broker-base-url=https://your-broker.pactflow.io \
--broker-token=${PACT_BROKER_TOKEN}
name: Consumer Contract Tests
on: [push, pull_request]
jobs:
consumer-contract-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run test:contract
- name: Publish Pacts
if: github.ref == 'refs/heads/main'
run: |
npx pact-broker publish tests/pacts \
--consumer-app-version=${{ github.sha }} \
--branch=${{ github.ref_name }} \
--broker-base-url=${{ secrets.PACT_BROKER_URL }} \
--broker-token=${{ secrets.PACT_BROKER_TOKEN }}
- name: Can I Deploy?
if: github.ref == 'refs/heads/main'
run: |
npx pact-broker can-i-deploy \
--pacticipant=frontend-app \
--version=${{ github.sha }} \
--to-environment=production \
--broker-base-url=${{ secrets.PACT_BROKER_URL }} \
--broker-token=${{ secrets.PACT_BROKER_TOKEN }}
name: Provider Contract Verification
on:
push:
branches: [main]
# Webhook trigger from Pact Broker when new pacts are published
repository_dispatch:
types: [pact-changed]
jobs:
provider-verification:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Verify Provider Contracts
run: npm run test:provider-contract
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
GIT_COMMIT: ${{ github.sha }}
GIT_BRANCH: ${{ github.ref_name }}
CI: true
- name: Can I Deploy?
if: github.ref == 'refs/heads/main'
run: |
npx pact-broker can-i-deploy \
--pacticipant=user-service \
--version=${{ github.sha }} \
--to-environment=production \
--broker-base-url=${{ secrets.PACT_BROKER_URL }} \
--broker-token=${{ secrets.PACT_BROKER_TOKEN }}
import { MatchersV3 } from '@pact-foundation/pact';
const {
like, // Match by type, not exact value
eachLike, // Array where each element matches the example
string, // String type matcher
integer, // Integer type matcher
decimal, // Decimal type matcher
boolean, // Boolean type matcher
uuid, // UUID format matcher
datetime, // Date-time format matcher
date, // Date format matcher
time, // Time format matcher
regex, // Regex pattern matcher
fromProviderState, // Value from provider state
arrayContaining, // Array contains these elements (in any order)
atLeastOneLike, // Array with at least N elements matching
} = MatchersV3;
// Examples
const body = {
id: uuid(),
name: string('John'),
age: integer(30),
score: decimal(95.5),
active: boolean(true),
email: regex(/^.+@.+\..+$/, 'john@example.com'),
createdAt: datetime("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", '2024-01-15T10:30:00.000Z'),
tags: eachLike('tag1'),
address: like({
street: string('123 Main St'),
city: string('Anytown'),
}),
};
like() and regex() make contracts resilient.mainBranch and deployedOrReleased pacts.git rev-parse --short HEAD as the version.- name: Install QA Skills
run: npx @qaskills/cli add contract-testing-pact10 of 29 agents supported