GraphQL Contract Testing with Pact: Queries, Mutations, and Subscriptions
GraphQL presents a unique challenge for contract testing. REST APIs have stable URL paths and HTTP verbs — easy to record as contracts. GraphQL routes everything through a single endpoint (POST /graphql) with the operation in the request body. The schema is flexible and self-describing. So where does contract testing fit in?
The short answer: Pact still works for GraphQL, but you need to think about what you're actually protecting.
The Challenge of GraphQL Contract Testing
Schema validation (via graphql-inspector or introspection) can catch breaking changes — removed fields, changed argument types, incompatible type changes. That's schema-level protection.
But schema validation doesn't tell you whether the client's specific query still returns the fields it needs. A field can exist in the schema but be null for all queries matching a consumer's use case. A non-nullable field can become nullable. A list can become paginated.
Pact fills the gap between "field exists in schema" and "my specific query returns what I need".
Setting Up Pact for GraphQL
GraphQL queries are just HTTP POST requests with a JSON body. Pact treats them as HTTP interactions.
npm install --save-dev @pact-foundation/pact graphqlWriting Consumer Tests for GraphQL Queries
The consumer is a React app using Apollo Client to query a product-service GraphQL API.
// src/__tests__/product-graphql-consumer.test.js
const { Pact } = require('@pact-foundation/pact');
const { like, eachLike } = require('@pact-foundation/pact').Matchers;
const path = require('path');
const { fetchProductDetails } = require('../api/product-api');
const provider = new Pact({
consumer: 'storefront-app',
provider: 'product-graphql-service',
port: 4001,
dir: path.resolve(process.cwd(), 'pacts'),
logLevel: 'warn',
});
const PRODUCT_QUERY = `
query GetProduct($id: ID!) {
product(id: $id) {
id
name
price
description
images {
url
altText
}
inStock
}
}
`;
describe('Product GraphQL Consumer', () => {
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
afterEach(() => provider.verify());
describe('GetProduct query', () => {
beforeEach(() =>
provider.addInteraction({
state: 'a product with ID prod-42 exists',
uponReceiving: 'a GetProduct query for prod-42',
withRequest: {
method: 'POST',
path: '/graphql',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: {
query: PRODUCT_QUERY,
variables: { id: 'prod-42' },
},
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
data: {
product: {
id: like('prod-42'),
name: like('Widget Pro'),
price: like(29.99),
description: like('A high-quality widget'),
images: eachLike({
url: like('https://cdn.example.com/widget-pro.jpg'),
altText: like('Widget Pro product image'),
}),
inStock: like(true),
},
},
},
},
})
);
it('returns product details', async () => {
const product = await fetchProductDetails('prod-42');
expect(product.id).toBe('prod-42');
expect(product.name).toBe('Widget Pro');
expect(product.images.length).toBeGreaterThan(0);
});
});
});The key insight: the entire GraphQL query string is part of the request body in the contract. This is both a strength and a limitation.
Strength: The contract captures exactly which fields the consumer requests. If the provider removes the images field from the schema, the contract test fails — even if the field still exists somewhere.
Limitation: If the consumer changes its query (adds a new field), the pact changes too, and the provider must re-verify. This is correct behavior — the consumer's needs changed — but it means every query change triggers a contract verification cycle.
Partial Matching for GraphQL Responses
GraphQL responses often contain __typename and other metadata your consumer doesn't care about. Use partial matching:
willRespondWith: {
status: 200,
body: {
data: {
product: like({
id: 'prod-42',
name: 'Widget Pro',
price: 29.99,
// Don't assert on __typename or fields not used by this consumer
}),
},
},
},The like() matcher with an object means "this shape must exist but additional properties are allowed". The consumer is only protected against changes to the fields it uses.
Testing Mutations
Mutations follow the same pattern:
const ADD_TO_CART_MUTATION = `
mutation AddToCart($productId: ID!, $quantity: Int!) {
addToCart(productId: $productId, quantity: $quantity) {
cartId
items {
productId
quantity
lineTotal
}
total
}
}
`;
beforeEach(() =>
provider.addInteraction({
state: 'the user has an empty cart',
uponReceiving: 'an AddToCart mutation',
withRequest: {
method: 'POST',
path: '/graphql',
headers: { 'Content-Type': 'application/json' },
body: {
query: ADD_TO_CART_MUTATION,
variables: { productId: 'prod-42', quantity: 2 },
},
},
willRespondWith: {
status: 200,
body: {
data: {
addToCart: {
cartId: like('cart-88'),
items: eachLike({
productId: like('prod-42'),
quantity: like(2),
lineTotal: like(59.98),
}),
total: like(59.98),
},
},
},
},
})
);Mutation contracts are valuable for catching breaking schema changes before they reach production. If the team renames addToCart to addItemToCart, the consumer tests fail immediately.
Handling GraphQL Errors
GraphQL errors come back as 200 responses with an errors array (not HTTP 4xx). Contract them explicitly:
beforeEach(() =>
provider.addInteraction({
state: 'no product with ID invalid-id exists',
uponReceiving: 'a GetProduct query for a non-existent product',
withRequest: {
method: 'POST',
path: '/graphql',
body: {
query: PRODUCT_QUERY,
variables: { id: 'invalid-id' },
},
},
willRespondWith: {
status: 200,
body: {
data: { product: null },
errors: eachLike({
message: like('Product not found'),
extensions: like({ code: 'NOT_FOUND' }),
}),
},
},
})
);Your Apollo Client error handling relies on the errors structure. If the provider changes from returning errors[].extensions.code to errors[].code, your error handling silently breaks. Contracting it catches the change.
Field-Level Matchers
For complex response types, combine matchers to protect specific fields:
const { Matchers } = require('@pact-foundation/pact');
const { like, term, integer, decimal, iso8601DateTimeWithMillis } = Matchers;
body: {
data: {
order: {
id: term({ generate: 'ord-42', matcher: '^ord-[0-9]+$' }),
status: term({
generate: 'CONFIRMED',
matcher: '^(PENDING|CONFIRMED|SHIPPED|DELIVERED|CANCELLED)$',
}),
placedAt: iso8601DateTimeWithMillis('2024-01-15T10:30:00.000Z'),
lineItems: eachLike({
productId: like('prod-7'),
quantity: integer(2),
unitPrice: decimal(29.99),
}),
},
},
},The term() matcher for status is particularly useful for enums — it validates the value matches the known enum values, so if a new status is added it passes, but if CONFIRMED is removed or renamed it fails.
Subscription Contract Patterns
Subscriptions are harder. They use WebSockets, not HTTP — Pact's HTTP mock server can't handle them directly.
For subscriptions, you have two practical options:
Option 1: Test the subscription handler in isolation. Extract the logic that processes subscription messages into a pure function, then test it with Pact message contracts (same approach as Kafka/SQS message contracts).
// Consumer message test for GraphQL subscriptions
const subscriptionPact = new MessageConsumerPact({
consumer: 'order-tracker-app',
provider: 'order-graphql-service',
dir: path.resolve(process.cwd(), 'pacts'),
});
it('handles OrderStatusUpdated subscription events', () => {
return subscriptionPact
.given('an order is in transit')
.expectsToReceive('an OrderStatusUpdated event')
.withContent({
data: {
orderStatusUpdated: {
orderId: like('ord-42'),
status: like('SHIPPED'),
updatedAt: like('2024-01-15T14:30:00Z'),
trackingUrl: like('https://track.carrier.com/abc123'),
},
},
})
.withMetadata({ contentType: 'application/json' })
.verify(asynchronousBodyHandler(handleSubscriptionEvent));
});Option 2: Use graphql-inspector for subscription schema checks. Schema-level compatibility checking catches field removals and type changes without needing Pact.
For most teams, option 1 for critical subscription payloads + option 2 for schema-wide change detection is the right combination.
Comparing Pact with graphql-inspector
| Aspect | Pact | graphql-inspector |
|---|---|---|
| What it checks | Consumer's actual query vs provider response | Schema-level field/type compatibility |
| False positives | Low — only breaks if consumer's used fields change | Higher — any schema change flags, even unused fields |
| Setup | Consumer test + provider verification | Schema diff in CI |
| Per-consumer granularity | Yes — each consumer has its own contract | No — schema-wide only |
| Catches nullable changes | Yes (if consumer asserts non-null) | Yes |
| Catches pagination changes | If consumer uses pagination fields | No |
| CI integration | Broker webhook + provider CI | One graphql-inspector diff command |
The two tools are complementary:
- graphql-inspector as a cheap first line of defense — catches schema breaking changes in the PR that introduces them
- Pact for consumer-specific safety — catches changes that break a specific consumer's query even when the schema technically allows them
For a small team with 2-3 GraphQL consumers, start with graphql-inspector. Add Pact when you have consumers on different release cycles from the provider and need finer-grained compatibility guarantees.
Provider Verification for GraphQL
Provider verification works the same as HTTP — start your GraphQL server, run the Pact verifier against it. The mock server replays each interaction's recorded query against your real server.
// provider verification
return new Verifier({
provider: 'product-graphql-service',
providerBaseUrl: 'http://localhost:4000',
pactBrokerUrl: process.env.PACT_BROKER_BASE_URL,
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
stateHandlers: {
'a product with ID prod-42 exists': async () => {
await db.products.upsert({ id: 'prod-42', name: 'Widget Pro', price: 29.99 });
},
},
requestFilter: (req, res, next) => {
// Add auth header if your GraphQL endpoint requires it
req.headers['Authorization'] = `Bearer ${TEST_AUTH_TOKEN}`;
next();
},
}).verifyProvider();The requestFilter option is useful for GraphQL APIs that require authentication — you can inject a valid test token before each interaction is replayed.
When GraphQL Contract Testing Is Worth It
GraphQL contract testing with Pact adds the most value when:
- Multiple teams own different consumers of the same GraphQL API
- The API is public or semi-public and breaking changes affect external parties
- Consumers are on different release cycles (mobile apps that can't force-update)
- You have a GraphQL gateway in front of multiple services and want per-service contracts
It's less valuable when:
- One team owns both the schema and all consumers (just coordinate directly)
- You're using a code-first schema approach where consumer queries are generated from the same type definitions as the server
For teams already using Pact for REST APIs, adding GraphQL contracts is a natural extension — the workflow is identical, only the body format changes.