Cross-Team Contract Testing for Micro-Frontends

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 ProductCard remote. It exports a ProductCard component with a product prop.
  • Team B's shell consumes it and mocks it in tests.
  • Team A refactors: product.imageUrl becomes product.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/pact

Writing 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:

  1. Consumer team changes their code → consumer contract tests run → new pact file generated → published to Pact Broker
  2. 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 verification

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

Read more

Start now free