Contract Testing Between Micro-Frontends
Micro-frontends communicate through defined interfaces: federated module exports, custom events, shared state, and props passed from shell to remote. When one team changes their interface without notifying consumers, the entire application breaks — and in production.
Contract testing formalizes these interfaces so both sides can verify compatibility independently, without deploying the full system.
What Counts as a Contract in Micro-Frontends
Federated module exports: The component API — prop names, types, required vs optional, callback signatures.
Custom events: Event names, payload shapes, when events are fired.
Shared state schema: Keys in shared Redux/Zustand/Jotai stores, their types.
Environment variables: Names and types of env vars consumed by remotes.
URL routing conventions: Route patterns that remotes handle, query params they expect.
Pact for Micro-Frontend Contracts
Pact is a consumer-driven contract testing framework originally designed for HTTP APIs, but it works for any interface contract. The consumer defines the contract; the provider verifies it.
For micro-frontends, the shell (consumer) defines what it expects from each remote (provider).
npm install --save-dev @pact-foundation/pactConsumer: Shell Defines the Contract
// shell/src/contracts/product-widget.pact.test.js
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
const { like, string, number, boolean } = MatchersV3;
const provider = new PactV3({
consumer: 'shell',
provider: 'product-widget',
dir: path.resolve(__dirname, '../../../pacts'),
logLevel: 'warn',
});
describe('Shell <> ProductWidget contract', () => {
// Contract: ProductCard component accepts a product object and onAddToCart callback
it('ProductCard renders a product and calls onAddToCart', async () => {
await provider.executeTest(async () => {
// Import the actual ProductCard component from the remote
// (in a real setup, this imports from the published npm package or bundle)
const { ProductCard } = await import('product-widget/ProductCard');
const mockProduct = {
id: 1,
name: 'Widget Pro',
price: 29.99,
stock: 5,
imageUrl: 'https://example.com/image.jpg',
};
const onAddToCart = vi.fn();
render(<ProductCard product={mockProduct} onAddToCart={onAddToCart} />);
// Assert the component renders the name
expect(screen.getByText('Widget Pro')).toBeInTheDocument();
// Assert the component renders the price
expect(screen.getByTestId('price')).toBeInTheDocument();
// Assert clicking calls onAddToCart with the product
await userEvent.click(screen.getByRole('button', { name: /add/i }));
expect(onAddToCart).toHaveBeenCalledWith(
expect.objectContaining({ id: 1, name: 'Widget Pro' })
);
});
// Write the contract to file
await provider.writePact();
});
});Provider: Remote Verifies the Contract
The ProductWidget team runs the consumer's pact file against their actual component to verify they haven't broken it:
// product-widget/src/contracts/shell-consumer.pact.test.js
import { PactV3 } from '@pact-foundation/pact';
import path from 'path';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductCard } from '../components/ProductCard';
const provider = new PactV3({
consumer: 'shell',
provider: 'product-widget',
pactUrls: [path.resolve(__dirname, '../../../pacts/shell-product-widget.json')],
logLevel: 'warn',
});
describe('ProductWidget verifies shell contract', () => {
it('satisfies all contracts from shell consumer', async () => {
await provider.verifyProvider(async (interaction) => {
if (interaction.description === 'ProductCard renders a product and calls onAddToCart') {
const onAddToCart = vi.fn();
const product = { id: 1, name: 'Widget Pro', price: 29.99, stock: 5 };
render(<ProductCard product={product} onAddToCart={onAddToCart} />);
expect(screen.getByText('Widget Pro')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button'));
expect(onAddToCart).toHaveBeenCalled();
}
});
});
});Custom Schema-Based Contract Testing
For teams not using Pact, a simpler approach uses JSON Schema or TypeScript types as the contract:
// contracts/product-widget.contract.ts
import { z } from 'zod';
// The contract: ProductCard's required props
export const ProductSchema = z.object({
id: z.number(),
name: z.string().min(1),
price: z.number().positive(),
stock: z.number().int().min(0),
imageUrl: z.string().url().optional(),
category: z.string().optional(),
});
export const ProductCardPropsSchema = z.object({
product: ProductSchema,
onAddToCart: z.function().args(ProductSchema).returns(z.void()),
className: z.string().optional(),
});
export type ProductCardProps = z.infer<typeof ProductCardPropsSchema>;Consumer test — shell validates the contract is what it expects:
// shell/src/contracts/product-card.contract.test.ts
import { ProductCardPropsSchema, ProductSchema } from '~/contracts/product-widget.contract';
describe('ProductCard contract — consumer (shell)', () => {
it('product schema accepts valid product', () => {
const validProduct = {
id: 1,
name: 'Widget Pro',
price: 29.99,
stock: 5,
};
expect(() => ProductSchema.parse(validProduct)).not.toThrow();
});
it('product schema rejects missing required fields', () => {
const missingName = { id: 1, price: 29.99, stock: 5 };
expect(() => ProductSchema.parse(missingName)).toThrow();
});
it('product schema rejects negative price', () => {
const negativePriceProduct = { id: 1, name: 'Widget', price: -1, stock: 5 };
expect(() => ProductSchema.parse(negativePriceProduct)).toThrow();
});
it('props schema validates complete valid props', () => {
const validProps = {
product: { id: 1, name: 'Widget', price: 9.99, stock: 3 },
onAddToCart: (p: any) => {},
};
expect(() => ProductCardPropsSchema.parse(validProps)).not.toThrow();
});
});Provider test — ProductWidget team validates their component still satisfies the contract:
// product-widget/src/contracts/product-card.contract.test.ts
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductCardPropsSchema, type ProductCardProps } from '~/contracts/product-widget.contract';
import { ProductCard } from '../components/ProductCard';
describe('ProductCard contract — provider (product-widget)', () => {
const validProps: ProductCardProps = {
product: { id: 1, name: 'Widget Pro', price: 29.99, stock: 5 },
onAddToCart: vi.fn(),
};
it('accepts all props defined in the contract', () => {
// Contract validation: if this throws, the component doesn't match the contract
const parsedProps = ProductCardPropsSchema.parse(validProps);
expect(() => render(<ProductCard {...parsedProps} />)).not.toThrow();
});
it('renders product name from props', () => {
render(<ProductCard {...validProps} />);
expect(screen.getByText('Widget Pro')).toBeInTheDocument();
});
it('calls onAddToCart with the product when button clicked', async () => {
const user = userEvent.setup();
const onAddToCart = vi.fn();
render(<ProductCard {...validProps} onAddToCart={onAddToCart} />);
await user.click(screen.getByRole('button', { name: /add/i }));
expect(onAddToCart).toHaveBeenCalledWith(
expect.objectContaining({ id: 1, name: 'Widget Pro' })
);
});
});Testing Custom Event Contracts
Micro-frontends often communicate via custom DOM events. Test the event contract:
// contracts/cart-events.contract.ts
import { z } from 'zod';
export const CartItemAddedEventSchema = z.object({
type: z.literal('cart:item-added'),
detail: z.object({
productId: z.number(),
productName: z.string(),
price: z.number().positive(),
quantity: z.number().int().positive(),
}),
});
export const CartItemRemovedEventSchema = z.object({
type: z.literal('cart:item-removed'),
detail: z.object({
productId: z.number(),
}),
});// product-widget/tests/cart-event-contract.test.ts
import { CartItemAddedEventSchema } from '~/contracts/cart-events.contract';
import { ProductCard } from '../components/ProductCard';
describe('ProductCard emits correct cart events', () => {
it('emits cart:item-added with correct shape when adding to cart', async () => {
const user = userEvent.setup();
const dispatchedEvent = await new Promise<CustomEvent>((resolve) => {
document.addEventListener('cart:item-added', (e) => resolve(e as CustomEvent), { once: true });
render(
<ProductCard
product={{ id: 42, name: 'Widget', price: 9.99, stock: 3 }}
onAddToCart={() => {}}
/>
);
user.click(screen.getByRole('button', { name: /add/i }));
});
// Validate the event against the contract
const parsed = CartItemAddedEventSchema.safeParse({
type: dispatchedEvent.type,
detail: dispatchedEvent.detail,
});
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.detail.productId).toBe(42);
expect(parsed.data.detail.price).toBe(9.99);
}
});
});Automating Contract Validation in CI
# .github/workflows/contract-validation.yml
name: Contract Validation
on:
pull_request:
paths:
- 'packages/product-widget/**'
- 'contracts/**'
jobs:
validate-contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run consumer contract tests (shell)
run: npm run test:contracts --workspace=shell
- name: Run provider verification (product-widget)
run: npm run test:contracts --workspace=product-widget
- name: Check for contract drift
run: node scripts/check-contract-drift.jsThe check-contract-drift.js script diffs the current contract file against the last published version and fails if breaking changes were introduced without a major version bump.
Breaking Change Detection
When a provider team changes their interface, add a breaking change check:
// scripts/check-contract-drift.js
import { execSync } from 'child_process';
import fs from 'fs';
const CONTRACTS_DIR = './contracts';
// Compare current contracts against main branch
const changedFiles = execSync('git diff --name-only origin/main -- contracts/').toString().split('\n');
for (const file of changedFiles.filter(Boolean)) {
const currentContent = fs.readFileSync(file, 'utf-8');
const mainContent = execSync(`git show origin/main:${file}`).toString();
// Detect removed exports (breaking change)
const mainExports = extractExports(mainContent);
const currentExports = extractExports(currentContent);
const removedExports = mainExports.filter((e) => !currentExports.includes(e));
if (removedExports.length > 0) {
console.error(`BREAKING CHANGE in ${file}: Removed exports: ${removedExports.join(', ')}`);
process.exit(1);
}
}
console.log('No breaking changes detected in contracts.');Summary
Contract testing between micro-frontends:
- Define contracts explicitly — as Pact interactions, JSON Schema, or Zod schemas
- Consumer-driven — the shell defines what it needs from each remote
- Provider verification — each remote team runs the consumer's contract against their actual implementation
- Test custom events — validate event type names, payload shapes, and when events fire
- Automate in CI — run contract tests on every PR that touches shared interfaces
- Detect breaking changes — diff contract files against main to catch removals and type changes
Contract testing catches the micro-frontend integration bugs that unit tests miss: the shell expects a productId prop, but the remote renamed it to id. Without contract tests, you find this in production.