Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Oct 13, 2026·13 min read

Testing Strategy: Contracts, Integration Boundaries, and E2E Scope

In a micro-frontend system, the boundary between teams is an API — test it like one. Unit tests stay local, contract tests guard boundaries, and E2E tests cover critical paths only. This article redraws the testing pyramid for distributed frontend systems.

Testing Strategy: Contracts, Integration Boundaries, and E2E Scope

A micro-frontend is not a smaller app — it is a domain boundary enforced at the deployment layer. If you can't explain the business capability it owns, you haven't drawn the boundary yet.
The default response to "how do we test a micro-frontend system" is to write more end-to-end tests. All remotes are deployed to a shared staging environment. Playwright starts a browser, navigates through the entire user journey, and asserts the expected result. When a test fails, it could be the Checkout remote, the Catalog remote, the shell's routing, the auth service, the API gateway, or the network between any of them. The test tells you something is wrong. It does not tell you whose code caused it, when the regression was introduced, or what the contractual expectation was.
In a micro-frontend system, the boundary between teams is an API. Test it like one: with a contract. Unit tests stay local and fast. Contract tests guard the boundary on every PR. E2E tests cover only the critical paths — the top 3–5 user journeys where orchestration failure is the highest risk.

1. The MFE Testing Pyramid

The standard testing pyramid (many unit tests, some integration, few E2E) applies to MFEs but with a critical addition: contract tests occupy the layer between integration and E2E.
Hierarchy diagram showing the MFE testing pyramid as a triangle divided into four horizontal layers. Bottom layer (widest, green): 'Unit Tests'. Label below: 'Scope: per-MFE. Owner: each remote team. Trigger: every PR commit. Tool: Vitest / Jest. Count: hundreds'. Second layer (medium-wide, cyan): 'Integration Tests'. Label below: 'Scope: remote mounted in controlled host shell. Owner: each remote team. Trigger: every PR. Tool: Testing Library + MSW. Count: tens'. Third layer (amber): 'Contract Tests'. Label below: 'Scope: remote exposed API vs. host consumer expectation. Owner: both teams jointly. Trigger: every PR on provider OR consumer. Tool: Pact. Count: tens'. Top layer (red, narrowest): 'E2E Tests'. Label below: 'Scope: critical full user journeys only. Owner: platform team. Trigger: pre-release and nightly. Tool: Playwright. Count: 5–15'. Right side of pyramid: vertical arrow labeled 'Confidence → Cost → Flakiness' pointing upward. Caption: 'Contract tests are the critical addition to the MFE testing pyramid — they guard the team boundary without the cost and flakiness of E2E tests.'
Contract tests are the critical addition to the MFE testing pyramid — they guard the team boundary without the cost and flakiness of E2E tests.

2. Unit Tests: Per-Remote, Fully Isolated

Unit tests in a micro-frontend system are identical to unit tests in any React application — they test a single component or hook in isolation, with mocked dependencies. The only MFE-specific rule: a remote's unit tests must never import from another remote's source code.
typescript
// checkout/src/components/CartSummary.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CartSummary } from './CartSummary'

// All dependencies are local to the checkout remote or mocked
const mockItems = [
  { id: '1', name: 'Laptop', price: 999, quantity: 1 },
  { id: '2', name: 'Mouse',  price: 49,  quantity: 2 },
]

describe('CartSummary', () => {
  it('calculates total correctly with quantity multipliers', () => {
    render(<CartSummary items={mockItems} onCheckout={vi.fn()} />)
    // 999*1 + 49*2 = 1097
    expect(screen.getByTestId('cart-total')).toHaveTextContent('$1,097.00')
  })

  it('calls onCheckout with current items on button click', async () => {
    const onCheckout = vi.fn()
    render(<CartSummary items={mockItems} onCheckout={onCheckout} />)
    await userEvent.click(screen.getByRole('button', { name: /proceed to checkout/i }))
    expect(onCheckout).toHaveBeenCalledWith(mockItems)
  })
})
Pro Tip & Optimization
Use MSW (Mock Service Worker) to mock API calls in unit and integration tests. MSW intercepts at the network level — your component code never needs to know it is running in a test environment. This means your tests exercise the same code path as production, including error handling in fetch calls.

3. Integration Tests: The Remote-in-Shell Pattern

Integration tests mount a remote application inside a minimal host shell — exercising the remote's component tree, routing, and event handling without deploying to a real environment.
typescript
// checkout/src/tests/checkout-integration.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { server } from '../mocks/server'  // MSW server
import { http, HttpResponse } from 'msw'
import CheckoutApp from '../App'

// Minimal host shell for testing — no federation runtime required
function TestShell({ children }: { children: React.ReactNode }) {
  return (
    <MemoryRouter initialEntries={['/checkout/cart']}>
      <AuthContext.Provider value={{ user: mockUser, isAuthenticated: true }}>
        {children}
      </AuthContext.Provider>
    </MemoryRouter>
  )
}

describe('Checkout Remote — Integration', () => {
  it('renders cart items from the cart API', async () => {
    server.use(
      http.get('/api/checkout/cart', () =>
        HttpResponse.json({ items: mockItems, total: 1097 })
      )
    )

    render(<TestShell><CheckoutApp /></TestShell>)

    await waitFor(() => {
      expect(screen.getByText('Laptop')).toBeInTheDocument()
      expect(screen.getByText('$1,097.00')).toBeInTheDocument()
    })
  })

  it('dispatches cart:updated event on checkout completion', async () => {
    const eventSpy = vi.fn()
    window.addEventListener('cart:updated', eventSpy)

    render(<TestShell><CheckoutApp /></TestShell>)

    // ... complete checkout flow ...

    await waitFor(() => {
      expect(eventSpy).toHaveBeenCalledWith(
        expect.objectContaining({ detail: { itemCount: 0 } })
      )
    })

    window.removeEventListener('cart:updated', eventSpy)
  })
})
The integration test specifically exercises:
  1. The remote's routing (does /checkout/cart render the correct component?)
  2. The remote's API integration (does it call the correct endpoints?)
  3. The remote's event contracts (does it dispatch the correct Custom Events?)

4. Consumer-Driven Contract Testing with Pact

4.1 What Contract Testing Guards

A contract test defines the expectation of a consumer (the host, which imports checkout/Cart) against the provider (the checkout remote, which exposes Cart). If the provider changes its exposed API in a way that breaks the consumer's expectation, the provider's CI pipeline fails — before that change reaches a shared environment.
The key property: contract tests catch breaking changes at the provider's CI stage, not at the E2E stage after deployment. This shifts risk discovery from hours to minutes.

4.2 Writing the Consumer Contract

typescript
// shell/src/contracts/checkout-cart.pact.ts
import { pactWith } from 'jest-pact'
import { like, string, integer } from '@pact-foundation/pact/src/dsl/matchers'

// Tested with @pact-foundation/pact v12.x
pactWith(
  { consumer: 'shell', provider: 'checkout', logLevel: 'warn' },
  (provider) => {
    describe('Cart component contract', () => {
      beforeEach(() =>
        provider.addInteraction({
          state: 'a cart with 2 items exists',
          uponReceiving: 'a request to render Cart with valid props',
          withRequest: {
            // The consumer's expectation of what props Cart accepts
            component: 'Cart',
            props: {
              onCheckout: like(Function),
            },
          },
          willRespondWith: {
            // The provider must expose a Cart component with these characteristics
            renders: true,
            emits: [
              {
                event: 'cart:updated',
                detail: { itemCount: integer() },
              },
            ],
          },
        })
      )

      it('Shell can render Checkout/Cart and receive cart:updated events', async () => {
        // Consumer test: shell mounts Cart and verifies the interface
        const { Cart } = await import('checkout/Cart')
        const onCheckout = vi.fn()
        render(<Cart onCheckout={onCheckout} />)

        // Verify the consumer's expectations are met
        expect(screen.getByTestId('cart-container')).toBeInTheDocument()
      })
    })
  }
)

4.3 Verifying the Provider Contract

typescript
// checkout/src/contracts/cart.provider.test.ts
import { Verifier } from '@pact-foundation/pact'

// Tested with @pact-foundation/pact v12.x
describe('Checkout remote — contract verification', () => {
  it('satisfies all consumer contracts from Pact Broker', async () => {
    const verifier = new Verifier({
      provider: 'checkout',
      // Pact Broker URL — where consumer contracts are published
      pactBrokerUrl: process.env.PACT_BROKER_URL,
      pactBrokerToken: process.env.PACT_BROKER_TOKEN,
      // Provider verification: does our Cart satisfy the shell's expectations?
      providerBaseUrl: 'http://localhost:3001',  // checkout dev server
      publishVerificationResult: true,
      providerVersion: process.env.GIT_SHA,
    })

    await verifier.verifyProvider()
  })
})

4.4 The Contract Lifecycle in CI

Flow trace diagram of the Pact Consumer-Driven Contract Testing lifecycle across two CI pipelines. Left pipeline labeled 'Shell (Consumer) CI'. Right pipeline labeled 'Checkout (Provider) CI'. Shell CI steps: (1) run unit tests, (2) run shell pact consumer tests — generates pact file, (3) publish pact file to Pact Broker (amber, network). Pact Broker box in the center stores the contract. Provider CI steps: (1) run unit tests, (2) download all consumer pacts from Pact Broker (amber, network), (3) run provider verification tests against downloaded pacts (green, can-i-deploy check), (4) publish verification result to Pact Broker. Two decision diamonds at end of Provider CI: 'All consumer contracts satisfied?' — Yes → green 'Proceed to deploy'. No → red 'CI blocked — contract violation found'. Dashed line connects Pact Broker to both pipelines. Caption: 'Consumer-Driven Contract Testing: the provider cannot deploy if any consumer contract is violated — risk is caught at the provider's CI, not after production deployment.'
Consumer-Driven Contract Testing: the provider cannot deploy if any consumer contract is violated — risk is caught at the provider's CI, not after production deployment.
bash
# Add to checkout CI pipeline — can-i-deploy check before any deployment
npx pact-broker can-i-deploy \
  --pacticipant checkout \
  --version $GIT_SHA \
  --to-environment production \
  --broker-base-url $PACT_BROKER_URL

# Exits non-zero if any consumer's contract is not satisfied
# This prevents the checkout remote from deploying a breaking change

5. Type Contracts as Build-Time Integration Tests

From Part 4, @module-federation/dts-plugin generates and distributes TypeScript declaration files. These type bundles are not just developer convenience — they are executable integration tests:
typescript
// checkout/src/components/Cart.tsx — provider changes a prop name
interface CartProps {
  // ❌ Renamed: itemCount → cartCount (breaking change)
  cartCount: number      // was: itemCount
  onCheckout: () => void
}
export function Cart({ cartCount, onCheckout }: CartProps) { /* ... */ }
bash
# shell CI: dts-plugin downloads new checkout type bundle
# TypeScript compilation catches the breaking change immediately

npx tsc --noEmit
# error TS2322: Type '{ itemCount: number; onCheckout: () => void; }' is not
#              assignable to type 'CartProps'.
#   Object literal may only specify known properties, and 'itemCount' does not
#   exist in type 'CartProps'. Did you mean to write 'cartCount'?
The shell's CI pipeline fails with a clear error — before the checkout remote is deployed. No E2E test run is required. No staging environment is needed.
Crucial Requirement
Run tsc --noEmit as a dedicated CI step after consumeTypes downloads the latest remote type bundles. Treat dts compilation errors as integration failures — they are evidence that a provider changed a contract without coordinating with its consumers.

6. E2E Scope Discipline

6.1 The E2E Anti-Pattern

E2E tests are expensive: slow to run (60–300 seconds per test), brittle to infrastructure (network timeouts, shared staging data), and wide in failure scope (any service in the chain can cause failure). In a micro-frontend system with 5 remotes, the number of possible failure sources in an E2E test grows multiplicatively.
The correct scope for E2E tests in an MFE system is orchestration — testing that the shell correctly loads remotes, that navigation between remotes works, and that the critical user journeys succeed end-to-end. Component behavior and API integration belong in unit and contract tests.
typescript
// tests/e2e/critical-journeys.spec.ts — Playwright
// These are the ONLY journeys that justify E2E scope

test.describe('Critical User Journeys', () => {
  test('complete purchase — guest checkout to order confirmation', async ({ page }) => {
    // Tests: shell routing → catalog remote loads → checkout remote loads →
    //        auth event propagation → order confirmation renders
    await page.goto('/catalog')
    await page.click('[data-testid="product-laptop"]')
    await page.click('[data-testid="add-to-cart"]')
    await page.click('[data-testid="checkout-button"]')
    // ... complete checkout ...
    await expect(page.locator('[data-testid="order-confirmation"]')).toBeVisible()
  })

  test('shell loads all three remotes without errors', async ({ page }) => {
    // Tests: no remote crashes, no Error Boundary fallbacks rendered
    await page.goto('/')
    await expect(page.locator('[data-testid="remote-error-boundary"]')).not.toBeVisible()
  })

  test('auth signout propagates to all mounted remotes', async ({ page }) => {
    await loginUser(page)
    await page.goto('/checkout/cart')
    await page.click('[data-testid="signout-button"]')
    // All remotes should reflect signed-out state
    await expect(page.locator('[data-testid="cart-user-name"]')).not.toBeVisible()
    await expect(page.locator('[data-testid="catalog-user-icon"]')).not.toBeVisible()
  })
})
Pro Tip & Optimization
If your E2E test suite has more than 15 tests, audit it: every test that could be a contract test or an integration test should be downgraded. E2E tests for MFE systems should exist in the tens, not hundreds.

7. Visual Regression Testing at the Remote Boundary

CSS bleed — where one remote's styles leak into another's rendered output — is a correctness problem that unit and contract tests cannot catch, but E2E tests are too slow to run on every PR. Visual regression testing at the component level fills this gap:
typescript
// catalog/src/tests/visual/ProductCard.test.ts — Playwright component test
import { test, expect } from '@playwright/experimental-ct-react'
import { ProductCard } from '../components/ProductCard'

test('ProductCard renders correctly without shell styles loaded', async ({ mount }) => {
  const component = await mount(
    <ProductCard
      name="Laptop Pro 15"
      price={1299}
      rating={4.5}
      imageUrl="/test-assets/laptop.jpg"
    />
  )
  // Snapshot test — any style bleed from shell or other remotes causes diff
  await expect(component).toHaveScreenshot('product-card-isolated.png')
})
Running this in the Catalog remote's CI (without the shell's global stylesheet loaded) isolates any visual regression to the Catalog remote itself, before CSS from the composition layer can interfere.

Summary

Testing LayerScopeTriggerToolTarget Count
UnitPer-component, per-hookEvery commitVitest / JestHundreds
IntegrationRemote in minimal shellEvery PRTesting Library + MSWTens
Contract (Pact)Remote exposed APIEvery PR (both sides)Pact v12Tens
Type ContractTypeScript dts bundleHost buildtsc --noEmitZero errors
Visual RegressionComponent isolatedEvery PRPlaywright CTPer-component
E2ECritical journeys, orchestrationPre-release, nightlyPlaywright5–15

What's Next

In Part 8, we solve the style isolation problem — ranking every CSS isolation mechanism by isolation strength, explaining when Shadow DOM is worth the interoperability cost, and defining what should and should not cross a design system boundary. Part 8 → Styling Isolation and Design System Distribution

References

  1. Pact — Consumer-Driven Contract Testing
  2. @pact-foundation/pact v12 Changelog
  3. MSW — Mock Service Worker
  4. Playwright — Component Testing
  5. Testing Library — React
  6. Pact Broker — Documentation
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Micro-Frontends#Testing#Contract Testing#Pact#E2E#Playwright#CI/CD
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.