Contract Testing Between Frontend Teams #

When two teams own a host and a remote, a breaking change to the exposed component’s props, events, or shared event-bus messages should fail a CI gate before the remote ever deploys — not surface as a runtime crash in production.

This guide shows how to build consumer-driven contract tests on the federation seam: the host (consumer) declares what it depends on, the remote (provider) verifies those expectations in CI, and a broken contract blocks the remote’s deploy. This is the enforcement layer that makes decoupling frontend teams without sacrificing UX safe to do at speed.

The federation seam is an untyped API #

In a federated app, the host imports a component from a remote at runtime. The remote’s owners can change a prop name, drop an event, or reshape a bus payload and ship it independently — the whole point of Managing Cross-Team Coupling is that they can deploy without coordinating.

The downside is that the import resolves at runtime, so a mismatch is not a compile error in either repo’s build. The host renders a stale assumption against a new component and breaks in the browser. Contract testing pins that seam down: it turns the implicit “I expect a userId: string prop and an onCheckout event” into an artifact that both repos test against.

Consumer-driven contract flow across the federation seam The host writes expectations, which become a versioned contract that the remote verifies in a CI gate that can block deploy. Host (consumer) Declares expected props, events, bus messages Contract Versioned schema (typed + JSON) shared artifact Remote (provider) Verifies it still satisfies the contract CI gate Verification fails → remote deploy blocked
The host's expectations become a versioned contract the remote must verify in CI; a failed verification blocks the remote's deploy.

Prerequisites #

This guide assumes a Webpack 5 or Vite host and remote already wired with Module Federation, plus:

Step 1 — Define the contract as a typed + runtime schema #

The contract describes the exposed component’s props, the events it emits, and any shared bus messages. Define it once with zod so you get a TypeScript type and a runtime validator from one source.

// packages/checkout-contract/src/contract.ts
import { z } from "zod";

export const CONTRACT_VERSION = "2.1.0";

// Props the host passes INTO the remote component.
export const CheckoutProps = z.object({
  userId: z.string().uuid(),
  currency: z.enum(["USD", "EUR", "GBP"]),
  // Added in 2.1.0; optional keeps it backward compatible.
  promoCode: z.string().optional(),
});

// Events the remote emits back to the host (callback props).
export const CheckoutEvents = z.object({
  onCheckoutComplete: z
    .function()
    .args(z.object({ orderId: z.string(), total: z.number() }))
    .returns(z.void()),
  onCheckoutCancel: z.function().args().returns(z.void()),
});

// Shared event-bus message published by the remote.
export const OrderPlacedMessage = z.object({
  type: z.literal("order.placed"),
  payload: z.object({ orderId: z.string(), total: z.number() }),
});

export type CheckoutProps = z.infer<typeof CheckoutProps>;
export type CheckoutEvents = z.infer<typeof CheckoutEvents>;
export type OrderPlacedMessage = z.infer<typeof OrderPlacedMessage>;

If you prefer a transport-neutral artifact (for non-TypeScript consumers or a registry), also emit JSON Schema from the same zod source so the contract is one canonical document:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "[email protected]",
  "type": "object",
  "required": ["userId", "currency"],
  "properties": {
    "userId": { "type": "string", "format": "uuid" },
    "currency": { "type": "string", "enum": ["USD", "EUR", "GBP"] },
    "promoCode": { "type": "string" }
  },
  "additionalProperties": false
}

Publish this as @acme/checkout-contract with the version baked into CONTRACT_VERSION. Both repos depend on it.

Step 2 — The consumer (host) writes its expectations #

The host declares only what it actually uses. This is the consumer-driven part: the contract reflects real usage, so the remote can safely change anything the host does not depend on.

// host/src/__contracts__/checkout.consumer.test.ts
import { describe, it, expect } from "vitest";
import { CheckoutProps, OrderPlacedMessage, CONTRACT_VERSION } from "@acme/checkout-contract";

describe("host expects checkout remote contract", () => {
  it("passes the props the host actually renders with", () => {
    const propsHostSends = {
      userId: "11111111-1111-4111-8111-111111111111",
      currency: "USD" as const,
    };
    // Fails if the host sends a shape the contract no longer accepts.
    expect(() => CheckoutProps.parse(propsHostSends)).not.toThrow();
  });

  it("handles the order.placed bus message the host subscribes to", () => {
    const incoming = { type: "order.placed", payload: { orderId: "o_1", total: 42 } };
    const parsed = OrderPlacedMessage.parse(incoming);
    expect(parsed.payload.orderId).toBe("o_1");
  });

  it("is pinned to a known contract major version", () => {
    expect(CONTRACT_VERSION.startsWith("2.")).toBe(true);
  });
});

These tests run in the host’s own CI on every PR, so the host team learns immediately if they upgrade the contract package to a version that drops something they rely on.

Step 3 — The provider (remote) verifies against the contract #

The remote proves its real exposed component satisfies the contract. Render the component with contract-valid props and assert the events and bus messages it produces match the schema.

// remote/src/__contracts__/checkout.provider.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { CheckoutProps, OrderPlacedMessage } from "@acme/checkout-contract";
import { Checkout } from "../Checkout"; // the actual exposed component
import { bus } from "../bus";

describe("checkout remote satisfies the contract", () => {
  it("renders with contract-valid props", () => {
    const props = CheckoutProps.parse({
      userId: "11111111-1111-4111-8111-111111111111",
      currency: "EUR",
    });
    render(<Checkout {...props} onCheckoutComplete={vi.fn()} onCheckoutCancel={vi.fn()} />);
    expect(screen.getByRole("button", { name: /pay/i })).toBeInTheDocument();
  });

  it("emits onCheckoutComplete with a contract-shaped argument", async () => {
    const onComplete = vi.fn();
    render(
      <Checkout
        userId="11111111-1111-4111-8111-111111111111"
        currency="USD"
        onCheckoutComplete={onComplete}
        onCheckoutCancel={vi.fn()}
      />,
    );
    screen.getByRole("button", { name: /pay/i }).click();
    await vi.waitFor(() => expect(onComplete).toHaveBeenCalled());
    const arg = onComplete.mock.calls[0][0];
    // Provider's real output must validate against the contract.
    expect(() => z.object({ orderId: z.string(), total: z.number() }).parse(arg)).not.toThrow();
  });

  it("publishes order.placed messages that match the contract", () => {
    const published: unknown[] = [];
    bus.subscribe((m) => published.push(m));
    bus.publish({ type: "order.placed", payload: { orderId: "o_9", total: 19.99 } });
    expect(() => OrderPlacedMessage.parse(published[0])).not.toThrow();
  });
});

The key discipline: verify against the real component and the real bus, not a hand-written stub. A stub that you keep in sync by hand drifts and produces false greens (see troubleshooting).

Step 4 — Run verification as a CI gate that blocks deploy #

Wire the provider verification into the remote’s pipeline so a contract break fails before the deploy step. The deploy job depends on the verify job; if verify fails, deploy never runs.

# remote/.github/workflows/deploy.yml
name: checkout-remote
on:
  push:
    branches: [main]

jobs:
  contract-verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      # Pull the contract version the host currently consumes.
      - run: npm i @acme/checkout-contract@latest
      - run: npx vitest run src/__contracts__ --reporter=dot
        # A breaking prop/event/message change fails here.

  deploy:
    needs: contract-verify   # deploy is gated on a green contract
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - run: npm run deploy:remote

To also catch the opposite drift — a host that starts depending on something the latest remote dropped — run the host’s consumer tests against the contract on the host’s PRs, and optionally trigger them cross-repo when the contract version bumps:

# Trigger host consumer verification when the contract changes.
gh workflow run consumer-contract.yml \
  --repo acme/checkout-host \
  -f contract_version="$(node -p "require('@acme/checkout-contract/package.json').version")"

Step 5 — Version the contract #

Treat the contract package with semantic versioning so teams reason about compatibility instead of guessing. This dovetails with backward-compatible remote API contracts.

Change to the seam Version bump Effect
Add an optional prop or a new event minor (2.1.02.2.0) Hosts unaffected; safe to deploy
Add a field to a bus payload minor Existing subscribers ignore it
Rename or remove a prop / event major (2.x3.0.0) Hosts must migrate; gate blocks until they do
Tighten a type (e.g. string → enum) major Existing host values may now be invalid

Pin the host to a major range ("@acme/checkout-contract": "^2.0.0") so minor contract releases flow in automatically but a breaking 3.0.0 forces an explicit, reviewed upgrade.

Verification: confirm the gate actually fails #

Prove the gate works by introducing a breaking change and watching CI go red. Rename a required prop in the remote and run the provider test locally:

# In the remote: rename `userId` -> `customerId` in Checkout.tsx, then:
npx vitest run src/__contracts__

Expected output — the verification fails because the rendered component no longer satisfies the contract:

 FAIL  src/__contracts__/checkout.provider.test.tsx > renders with contract-valid props
   TypeError: Cannot read properties of undefined (reading 'userId')
 Test Files  1 failed
   Tests  1 failed | 2 passed

Because deploy has needs: contract-verify, the red verify job stops the remote from shipping the rename until the contract is bumped to a major and the host migrates.

Troubleshooting #

False greens from mocks drifting.

Symptom: contract tests stay green but production still breaks. Diagnosis: the provider test renders a hand-written mock or stub instead of the real exposed component, so it validates the stub, not reality. Fix: import the actual component and the actual bus into the provider test, exactly as the host imports them through federation. If you must mock a downstream dependency, never mock the component under contract itself.

Runtime-only breaks not caught by types.

Symptom: TypeScript compiles cleanly on both repos but a payload field is undefined at runtime. Diagnosis: shared types verify the shape the compiler sees, but a remote can emit data that violates the type at runtime (e.g. an API returns null). Fix: keep the runtime zod.parse assertions in the provider test — they exercise real values, not just declared types. Types from shared TypeScript types across federated remotes are necessary but not sufficient; the runtime schema is the safety net.

Flaky cross-repo verification.

Symptom: the gate fails intermittently with version-resolution or network errors when pulling the contract. Diagnosis: the verify job resolves @acme/checkout-contract@latest live, so a registry hiccup or an in-flight contract publish produces nondeterministic results. Fix: pin the contract to an exact version in package-lock.json, cache node_modules in CI, and publish the contract as an immutable artifact before any consumer or provider job depends on it. Run the bump as its own job that gates the others.

Contract and code bumped in the same PR.

Symptom: a major contract change merges with the remote change, so the gate never had a chance to block it. Diagnosis: the contract package and the remote that violates it shipped together. Fix: publish contract changes in a separate, earlier PR. The host’s consumer tests then go red against the new contract first, forcing the migration conversation before the remote deploys.