Testing and Local Development Workflows #

The cost of micro-frontends that teams actually feel day to day is not runtime complexity — it is that running the application locally now requires several processes, and that no test suite sees the composed page. Both are solvable, and neither is solved by default.

This guide covers the four workflows a federated codebase needs: fast unit tests that do not load remotes, a local setup that does not require every service, end-to-end tests against a real composition, and debugging that works across the seam. It sits under Webpack & Vite Module Federation Implementation and its step-by-step companions cover mocking remote modules in Jest and Vitest, running remotes locally against a deployed host, end-to-end testing federated apps with Playwright, and debugging Module Federation with source maps.

What breaks without a deliberate workflow #

How the developer experience degrades as remotes are added Each additional remote makes the same four workflows worse, and none of them are addressed by the federation configuration itself. The four things federation makes harder Unit tests stop resolving orders/Cart does not exist at build time · ad-hoc mocks appear in every file Local setup becomes expensive five dev servers per developer · onboarding takes a day Nothing tests the composition every suite green, page broken · integration exists only in production Debugging crosses a seam stack traces land in bundled output · no source for the other team's remote
Every one of these is a workflow problem rather than a runtime problem, which is why no amount of configuration fixes them.

The default experience degrades in a predictable order as remotes are added.

First, unit tests break. A component that imports orders/Cart cannot resolve that specifier under Jest or Vitest, because the module only exists at runtime through the federation container. Teams work around it by mocking ad hoc in each test file, and the mocks drift from the real contract.

Then local development becomes expensive. Running the host plus four remotes means five dev servers, five terminals, and enough memory that a laptop starts swapping. New joiners spend their first day on setup, and everyone eventually runs only their own remote against a stale local copy of the host.

Finally, nothing tests the composed page. Each team’s suite is green, each remote works standalone, and the integration exists only in production. The failures that reach users are exactly the ones no suite covers: a renamed prop, a shared dependency mismatch, a route two remotes both claim.

The through-line is that federation removes the composed application from every environment except production, and each of these workflows is about putting it back in one place cheaply.

Key objectives #

Setup: a testing strategy per layer #

Match the layer to what it can actually catch, and do not ask a layer to do a job it is bad at.

Layer What loads What it catches Where it runs
Unit Nothing federated Component logic in one remote Every remote’s CI
Contract The exposed module’s shape A renamed or removed prop Both sides’ CI
Composed integration Real remotes, real host Shared-dependency and routing failures Host CI, on merge
End to end The deployed preview User journeys across remotes Host CI, on merge

The layer teams most often skip is contract testing, and it is the one that would catch the majority of federated production incidents. It is cheap because it does not require a running application — only a shared description of what the seam looks like, verified in each side’s own pipeline, as described in contract testing between frontend teams.

Setup: making remote specifiers resolvable in tests #

Unit tests should never load a remote. The reliable way to guarantee that is a module alias that resolves every federated specifier to a local test double.

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import path from 'node:path';

export default defineConfig({
  resolve: {
    alias: [
      // Any specifier that looks like a remote resolves to a local double.
      { find: /^orders\/(.*)$/, replacement: path.resolve(__dirname, 'test/doubles/orders/$1') },
      { find: /^catalog\/(.*)$/, replacement: path.resolve(__dirname, 'test/doubles/catalog/$1') },
    ],
  },
  test: { environment: 'jsdom', setupFiles: ['./test/setup.ts'] },
});

An alias is better than a per-file mock for two reasons. It cannot be forgotten in a new test file, and it gives the doubles a single home where they can be checked against the contract rather than being scattered across the suite.

Integration: the local development matrix #

Four local development modes, by cost Three of the four modes need a single process, and the expensive one is needed far less often than teams assume. Mode Processes to run Right for Standalone remote 1 component work, fastest loop Remote against deployed host 1 integration work, daily default Host against deployed remotes 1 shell, routing and layout work Full local composition 4-6 changes that genuinely span the seam
Getting the second row working is the single highest-leverage investment in a federated developer experience.

The question a developer actually has is “I am changing one remote — what do I need running?” and the answer should almost never be “everything”.

Three modes cover nearly every case. In standalone mode the remote runs alone against its own entry point with mock data, which is right for component work and is the fastest loop. In remote-against-deployed-host mode the developer runs only their remote and points a deployed host at localhost, which is right for integration work and needs no other process. Full local composition is reserved for changes that genuinely span the seam, which is rarer than it feels.

Making the second mode work is the highest-leverage investment here, because it converts a five-process setup into a one-process setup for the overwhelming majority of daily work.

// host/src/resolve-remotes.js — a query parameter overrides one remote's origin
export function resolveRemotes(manifest) {
  const overrides = new URLSearchParams(location.search).getAll('remote');
  const resolved = { ...manifest };

  for (const override of overrides) {
    const [name, url] = override.split('@');
    // Only in non-production builds: never let a URL parameter load code in production.
    if (import.meta.env.MODE !== 'production' && resolved[name]) {
      resolved[name] = { ...resolved[name], entry: url };
    }
  }
  return resolved;
}

The production guard is not optional. An override that works in production is a remote-code-execution vector reachable from a link, and it undoes every control described in securing federated remotes.

Integration: one composed test environment #

Exactly one environment should run the real composition, and it should be a preview deployment rather than a developer’s laptop.

Building each remote at its current commit, deploying to a preview URL, and pointing an end-to-end suite at it gives every team a shared definition of “working”. It is also the only environment where a shared-dependency mismatch, a route collision or a version drift can be observed before users find it.

Keep the suite small. A composed test is slow and its value is in the seams, not in coverage: a handful of journeys that cross remote boundaries will catch nearly everything, and a hundred will produce a suite nobody trusts because it is always slightly broken.

Edge cases #

A remote is unavailable during a composed test. The test should assert the degradation rather than failing. If a remote being down makes your suite red, your suite is testing availability rather than behaviour, and it will be red often.

Two remotes at incompatible versions in a preview. This is a genuine finding, not a flaky test. Preview environments should resolve each remote at the version currently on its main branch, which is what makes drift visible before release.

A test double drifts from the real module. This is the failure mode of mocking, and the reason doubles must be generated from or verified against the exposed module’s types — see sharing TypeScript types across federated remotes.

Hot reload stops working after adding federation. Common with Vite, where several plugins require a build step for federation to work. Budget for it in onboarding rather than treating it as a bug.

Testing and validation #

The suite that matters most is the smallest one: a composed smoke test that loads each route and asserts every expected remote actually mounted.

// e2e/remotes-mounted.spec.js
import { test, expect } from '@playwright/test';

const ROUTES = {
  '/': ['header', 'hero'],
  '/browse': ['header', 'catalog'],
  '/checkout': ['header', 'cart', 'summary'],
};

for (const [route, remotes] of Object.entries(ROUTES)) {
  test(`${route} mounts ${remotes.join(', ')}`, async ({ page }) => {
    const failures = [];
    page.on('console', (m) => { if (m.type() === 'error') failures.push(m.text()); });

    await page.goto(process.env.PREVIEW_URL + route);
    for (const name of remotes) {
      await expect(page.locator(`[data-remote="${name}"]`)).toBeVisible();
    }
    expect(failures, failures.join('\n')).toHaveLength(0);
  });
}

This one test catches more real federated failures than any other single thing you can write, because every category of federation bug ends with a remote that did not mount.

Deployment: preview environments per pull request #

Running the host's suite inside the remote's pipeline The composed suite gates the remote's publish step, so a breaking change fails in the pipeline of the team that wrote it. Remote PR Preview deploy Host suite Merge 1. build and deploy this remote 2. host resolves the preview remote 3. run seam-crossing journeys 4. green: allowed to publish 5. red: blocked before publish
A composed suite that only runs in the host's repository finds the break after publication — one step too late to prevent it.

The workflow that makes all of this work is a preview deployment for every pull request, in both hosts and remotes.

A remote’s pull request deploys that remote to a preview URL and runs the host’s composed suite against a host configured to resolve it. A host’s pull request deploys the host and resolves every remote at its current main-branch version. Both directions matter: a breaking change can originate on either side of the seam.

# remote pipeline — deploy a preview, then let the host's suite judge it
- name: Deploy preview remote
  run: npx wrangler pages deploy dist --branch "pr-${{ github.event.number }}"

- name: Run the host's composed suite against it
  run: |
    npx playwright test --config=host-e2e.config.js
  env:
    PREVIEW_URL: https://host-preview.example.com
    REMOTE_OVERRIDE: cart@https://pr-${{ github.event.number }}.remote-preview.example.com/remoteEntry.js

The important property is that the host’s suite runs in the remote’s pipeline. A test that only runs in the host’s repository will find the break after it is published, which is exactly one step too late.

Making the test doubles trustworthy #

A test double is only useful while it still resembles the module it stands in for, and nothing about a hand-written mock enforces that. This is the quiet failure of every mocking strategy: the suite stays green precisely because the mock never changed, while the real remote moved underneath it.

Three techniques, in increasing order of strength, keep doubles honest.

The weakest and cheapest is typechecking the double against the remote’s published types. If the remote publishes declarations — as described in sharing TypeScript types across federated remotes — the double can be declared as implementing that type, and tsc will reject it when a required prop appears or a signature changes. This catches shape drift and nothing else, but shape drift is the majority of it.

// test/doubles/orders/Cart.tsx — the annotation is what makes this fail on drift
import type { CartProps } from 'orders/Cart';

export default function CartDouble(props: CartProps) {
  return <div data-testid="cart-double" data-items={props.items.length} />;
}

Stronger is generating the double from the contract rather than writing it. If the seam is already described by a schema for contract testing, the same schema can produce a double that returns valid data, and a regenerated double that differs from the committed one is a visible diff in the pull request.

Strongest, and worth it only for the seams that carry real complexity, is running the double’s assertions against the real module in a separate, slower job. The unit suite keeps using the double; a nightly job loads the real remote and asserts the same behaviours. When the two disagree, the double is wrong — and you learn it from a job that is allowed to be slow rather than from production.

Debugging across a seam #

Debugging in a federated application is different in one specific way: the code in front of you in the debugger frequently belongs to a repository you do not have checked out. Everything else follows from that.

The first requirement is that source maps exist and resolve for every remote, including in production. A stack trace that points at line 1 of a minified chunk is not usably better than no stack trace, and in a federated system it is worse, because it does not even tell you which team owns the code. Publishing maps to a location the browser can reach for authorised users — or uploading them to your error-reporting service so traces are symbolicated server-side — is what makes a cross-team incident tractable.

The second is that every error carries its remote’s identity and version. This is the same tagging discipline as error boundary telemetry for remote apps, and it is what turns “the checkout page is broken” into “[email protected] throws in its price formatter”, which routes itself.

The third is a way to reproduce with a local remote. This is where the override mode described above pays for itself a second time: an engineer investigating a production issue in someone else’s remote can check that remote out, run it locally, and point a production-like host at it — without any coordination and without a deploy.

Choosing what to build first #

If a team is starting from nothing, the order that delivers the most relief per unit of effort is fairly consistent.

Start with the module alias for federated specifiers, because it unblocks unit testing entirely and takes an afternoon. Add the composed smoke test next: a few dozen lines that load each route and assert every expected remote mounted, catching the largest class of real failures. Then build the remote-against-deployed-host mode, which is the change developers will thank you for daily. Preview deployments per pull request come after that, and full contract testing last — it has the highest value per incident prevented and also the highest setup cost, so it benefits from the infrastructure the earlier steps establish.

What not to build first is a comprehensive end-to-end suite. It is the most visible investment and the one most likely to end up disabled, because a slow suite that is red for reasons unrelated to the change teaches everyone to ignore it.

Common pitfalls #

Pitfall Root cause and resolution
Unit tests import a real remote and hang No alias for federated specifiers. Map them to local doubles in the test config.
Mocks pass while production breaks Doubles are hand-written and unverified. Generate or typecheck them against the exposed types.
Nobody runs the full app locally Five dev servers is genuinely too many. Support remote-against-deployed-host mode.
Composed suite is always slightly red Too many end-to-end tests. Keep a handful of seam-crossing journeys.
Breakage found after publishing The composed suite runs only in the host’s pipeline. Run it in the remote’s too.
Stack traces point at bundled output Source maps not published or not resolvable. Publish them and restrict access.

FAQ #

Should remotes be tested in isolation or only composed?

Both, at different layers. Isolation tests are fast, deterministic and owned by the team that can fix what they break. Composed tests are slow and catch a category isolation cannot see at all. The mistake is using one where the other belongs — a composed test asserting a button’s label is expensive and fragile, and a unit test can never catch a shared-dependency mismatch.

How many end-to-end tests should a federated app have?

Fewer than you expect. Cover each seam-crossing journey once — sign in, add to cart, check out — plus the smoke test that every expected remote mounts on every route. Depth belongs in each remote’s own suite where it runs in seconds.

Can we test against production remotes?

For a nightly smoke test, yes, and it is a useful early warning. For a pull-request gate, no: the test then depends on other teams’ deploy timing, and a red build that has nothing to do with the change is how teams learn to ignore CI.

What about visual regression across remotes?

Run it on the composed preview, not per remote. The failures worth catching — a remote overriding a shared token, a layout shift when a remote mounts — only exist in composition. Per-remote visual tests mostly catch the remote team’s own intentional changes.

Is a monorepo the answer to the local development problem?

It helps with dependency alignment and shared tooling, and it does not remove the need for these workflows. Remotes still build and deploy independently, so composed testing and per-remote local modes are still required. A monorepo makes the first mode cheaper; it does not make the third unnecessary.