Cross-Team Contract Testing for Micro-Frontends
Micro-frontend teams ship independently. That's the whole point. But independent shipping only works if every team respects the interfaces their code exposes and consumes. Without enforcement, breaking changes slip through: a prop gets renamed, an event payload changes shape, a shared utility gets a new required parameter. The consumer's tests don't catch it because they mock the dependency. The provider's tests don't catch it because they don't know what the consumer expects.
Contract testing solves this. It formalizes the agreement between a provider (remote) and consumer (shell or other remote) and verifies both sides continuously.
The Problem with Mock-Only Testing
Consider this scenario:
- Team A owns the
ProductCardremote. It exports aProductCardcomponent with aproductprop. - Team B's shell consumes it and mocks it in tests.
- Team A refactors:
product.imageUrlbecomesproduct.image.url. - Team A's tests pass. Team B's tests pass (they mock
ProductCard). - Production breaks.
Contract testing would have caught this at the point Team A changed the interface, before anything deployed.
Consumer-Driven Contracts with Pact
Pact works by having the consumer write a contract (what it expects from the provider) and having the provider verify that contract. For micro-frontends, the "requests" are component renders and event emissions rather than HTTP calls, but the principle is identical.
Install Pact:
npm install --save-dev @pact-foundation/pactWriting the Consumer Contract
Team B (shell) writes a test that defines what they expect from ProductCard:
// shell/src/contracts/ProductCard.consumer.pact.js
import { Pact } from '@pact-foundation/pact';
import { renderMFEComponent } from '../testUtils/mfeContractUtils';
const provider = new Pact({
consumer: 'Shell',
provider: 'ProductRemote',
port: 1234,
log: './pact/logs/pact.log',
dir: './pact/pacts',
});
describe('ProductCard contract', () => {
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
test('ProductCard renders with required product shape', async () => {
await provider.addInteraction({
state: 'a product exists',
uponReceiving: 'a request to render ProductCard',
withRequest: {
componentProps: {
product: {
id: '123',
name: 'Widget Pro',
price: 49.99,
image: { url: 'https://example.com/widget.jpg', alt: 'Widget' },
}
}
},
willRespondWith: {
renderedOutput: {
hasText: 'Widget Pro',
hasText: '$49.99',
hasImage: 'https://example.com/widget.jpg',
}
}
});
const { screen } = await renderMFEComponent('ProductRemote/ProductCard', {
product: {
id: '123',
name: 'Widget Pro',
price: 49.99,
image: { url: 'https://example.com/widget.jpg', alt: 'Widget' },
}
});
expect(screen.getByText('Widget Pro')).toBeInTheDocument();
expect(screen.getByText('$49.99')).toBeInTheDocument();
});
});Running this test generates a pact file at ./pact/pacts/Shell-ProductRemote.json. The consumer owns this file and publishes it to a Pact Broker.
Provider Verification
Team A (ProductRemote) verifies their component satisfies the pact:
// product-remote/src/contracts/ProductCard.provider.pact.js
const { Verifier } = require('@pact-foundation/pact');
const { renderToStaticMarkup } = require('react-dom/server');
const ProductCard = require('./ProductCard').default;
describe('ProductCard provider verification', () => {
test('satisfies all consumer contracts', () => {
return new Verifier({
provider: 'ProductRemote',
pactBrokerUrl: process.env.PACT_BROKER_URL,
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
publishVerificationResult: true,
providerVersion: process.env.GIT_COMMIT,
}).verifyProvider();
});
});If Team A renames product.image.url back to product.imageUrl, the provider verification fails. The pipeline blocks. The breaking change doesn't ship.
Testing Interface Contracts Between Shell and Remotes
Component props are one interface. There are others: custom events, shared state shape, mount function signatures.
Event Contract Testing
When the cart remote emits events that the shell listens to:
// consumer contract: what the shell expects from cart events
describe('Cart event contract', () => {
test('cart:item-added event has expected shape', () => {
const expectedEventShape = {
eventName: 'cart:item-added',
payload: {
productId: expect.any(String),
quantity: expect.any(Number),
price: expect.any(Number),
}
};
// Record the contract
pactBroker.recordConsumerContract({
consumer: 'Shell',
provider: 'CartRemote',
event: expectedEventShape,
});
});
});
// provider verification: cart remote proves it emits events matching the contract
describe('Cart event provider verification', () => {
test('emits cart:item-added with correct shape', () => {
const emittedEvents = [];
document.addEventListener('cart:item-added', (e) => {
emittedEvents.push(e.detail);
});
// Trigger an add-to-cart action
render(<CartRemote />);
fireEvent.click(screen.getByText('Add to Cart'));
expect(emittedEvents[0]).toMatchObject({
productId: expect.any(String),
quantity: expect.any(Number),
price: expect.any(Number),
});
});
});Mount Function Contract
The shell calls a mount function on each remote. Test that contract explicitly:
// shell/contracts/mountFunctionContract.test.js
describe('Remote mount function contract', () => {
const requiredMountProps = {
container: HTMLElement,
basePath: String,
onNavigate: Function,
user: Object, // nullable
eventBus: Object,
};
test('mount function accepts required props without throwing', () => {
const container = document.createElement('div');
expect(() => {
remoteMount({
container,
basePath: '/cart',
onNavigate: jest.fn(),
user: null,
eventBus: mockEventBus,
});
}).not.toThrow();
});
test('mount function returns unmount function', () => {
const container = document.createElement('div');
const result = remoteMount({ container, basePath: '/cart', /* ... */ });
expect(typeof result.unmount).toBe('function');
});
test('unmount cleans up DOM', () => {
const container = document.createElement('div');
const { unmount } = remoteMount({ container, basePath: '/cart', /* ... */ });
unmount();
expect(container.innerHTML).toBe('');
});
});Preventing Breaking Changes with Automated Contract Checks
The contract enforcement needs to be part of the CI pipeline. Here's the workflow:
- Consumer team changes their code → consumer contract tests run → new pact file generated → published to Pact Broker
- Provider team runs verification → fetches latest pact from broker → verifies against current code
In CI:
# product-remote/.github/workflows/test.yml
- name: Run contract verification
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
GIT_COMMIT: ${{ github.sha }}
PACT_PUBLISH_VERIFICATION_RESULTS: true
run: npm run test:contracts
- name: Can I Deploy check
run: |
npx pact-broker can-i-deploy \
--pacticipant ProductRemote \
--version ${{ github.sha }} \
--to-environment production \
--broker-base-url ${{ secrets.PACT_BROKER_URL }} \
--broker-token ${{ secrets.PACT_BROKER_TOKEN }}The can-i-deploy command queries the Pact Broker and blocks the deploy if any consumer contract is not verified against this version of the provider. This is the enforcement gate.
Bi-Directional Contracts
Standard Pact is consumer-driven: consumers write contracts, providers verify. Bi-directional contracts work differently — each side independently describes their interface, and the broker verifies compatibility.
This is useful when you already have OpenAPI specs, JSON schemas, or TypeScript types for your interfaces.
// Publish provider schema (Team A does this when they build)
pactBroker.publishProviderSchema({
provider: 'ProductRemote',
version: process.env.GIT_COMMIT,
schema: {
components: {
ProductCard: {
props: {
product: {
type: 'object',
required: ['id', 'name', 'price', 'image'],
properties: {
id: { type: 'string' },
name: { type: 'string' },
price: { type: 'number' },
image: {
type: 'object',
required: ['url'],
properties: {
url: { type: 'string' },
alt: { type: 'string' },
}
}
}
}
}
}
}
}
});
// Publish consumer expectations (Team B does this)
pactBroker.publishConsumerContract({
consumer: 'Shell',
provider: 'ProductRemote',
contract: {
components: {
ProductCard: {
usedProps: ['product.id', 'product.name', 'product.price', 'product.image.url']
}
}
}
});The broker checks that all usedProps exist in the provider schema. When Team A removes product.image.url, the compatibility check fails immediately — before any code is deployed.
Practical Contract Testing With TypeScript
If you use TypeScript, you can derive contracts from types directly:
// shared-contracts/ProductCard.ts
export interface ProductCardProps {
product: {
id: string;
name: string;
price: number;
image: {
url: string;
alt?: string;
};
};
onAddToCart?: (productId: string) => void;
}
// Generate a JSON schema from this type for Pact verificationUse typescript-json-schema to generate JSON Schema from TypeScript interfaces and feed that into bi-directional contract verification. The TypeScript compiler then becomes part of your contract enforcement — type errors are contract violations.
Setting Up a Pact Broker
For teams just starting, PactFlow (hosted) is the easiest option. For self-hosted:
# docker-compose.pact.yml
version: '3'
services:
pact-broker:
image: pactfoundation/pact-broker
ports:
- "9292:9292"
environment:
PACT_BROKER_DATABASE_URL: "sqlite:////tmp/pact_broker_app.sqlite3"Every team points their CI at the same broker URL. Contracts are versioned with git SHAs, and the broker tracks which versions are compatible.
What to Contract-Test
Not everything needs a contract test. Focus on:
- Component prop interfaces that cross team boundaries
- Events emitted by one team and consumed by another
- Mount/unmount function signatures
- Shared state shape that multiple teams read from
Skip contract testing for:
- Internal component APIs within one team's codebase
- Styling properties (use visual regression tests instead)
- Performance characteristics
Contract testing is overhead. Apply it where the cost of a breaking change is highest: the interfaces that cross team ownership boundaries.