Contract Testing Between Micro-Frontends

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/pact

Consumer: 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.js

The 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.

Read more