Mocking Remote Modules in Jest and Vitest #

A unit test that imports orders/Cart fails to resolve the module, because federated specifiers only exist at runtime inside the container — this page maps them to typed local doubles that the test runner can resolve and the type checker can police.

The goal is not merely to make the import work. A double that nobody checks against the real module turns a passing suite into a false negative, so most of this page is about keeping the doubles honest. It implements the unit-test layer described in testing and local development workflows.

How a federated specifier resolves inside a test runner The alias intercepts the specifier before module resolution, so no container is ever initialised and the test stays hermetic. One alias, applied to every federated import Test imports orders/Cart Runner alias regex match on the remote name Local double typed against the real module Test runs fast, offline, deterministic
Configuring this once in the runner is what stops a new test file from accidentally loading a real remote.

Prerequisites #

Step 1 — Map every federated specifier to a doubles directory #

Do this once in the runner configuration rather than per test file. A per-file mock is one someone will forget.

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

const remotes = ['orders', 'catalog', 'account'];

export default defineConfig({
  resolve: {
    alias: remotes.map((name) => ({
      find: new RegExp(`^${name}/(.*)$`),
      replacement: path.resolve(__dirname, `test/doubles/${name}/$1`),
    })),
  },
  test: { environment: 'jsdom', setupFiles: ['./test/setup.ts'] },
});

For Jest the equivalent lives in moduleNameMapper:

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  moduleNameMapper: {
    '^(orders|catalog|account)/(.*)$': '<rootDir>/test/doubles/$1/$2',
  },
  setupFilesAfterEach: ['<rootDir>/test/setup.ts'],
};

Driving both from one list of remote names means adding a remote is a one-line change, and a remote nobody added produces an unresolved import — a loud failure rather than a silent real load.

Step 2 — Write doubles that are typed against the real module #

An untyped double is a comment. Annotating it with the remote’s published prop type is what makes the type checker fail when the contract moves.

// test/doubles/orders/Cart.tsx
import type { CartProps } from 'orders/Cart';   // resolved from the published .d.ts

// The annotation is doing the work: a new required prop breaks tsc here.
export default function CartDouble({ items, currency, onCheckout }: CartProps) {
  return (
    <div data-testid="cart-double" data-currency={currency}>
      <span data-testid="cart-count">{items.length}</span>
      <button type="button" onClick={() => onCheckout?.({ items })}>checkout</button>
    </div>
  );
}

Two properties make this a good double. It renders something assertable, so tests can check that the host passed the right props. And it calls the callbacks it was given, so tests can verify the host handles them — which is where the real integration bugs live.

Keep doubles behaviourally minimal but structurally complete. A double that ignores onCheckout lets a host bug through; one that reimplements the remote’s business logic becomes a second implementation to maintain.

Step 3 — Make doubles configurable per test #

Four behaviours every double should support A small behaviour switch lets one double cover the states a host must handle, including the failure path that is otherwise untestable. Behaviour Renders Proves default the normal component the host passes the right props empty a zero-item state the host handles emptiness loading a pending placeholder the host reserves layout error throws on render the boundary contains the failure
The last row is the most valuable and the most often missing: without it, nothing verifies the error boundary exists.

A double with fixed behaviour forces tests to work around it. Give each one a small control surface.

// test/doubles/orders/Cart.tsx — a per-test override hook
type Behaviour = 'default' | 'empty' | 'error' | 'loading';
let behaviour: Behaviour = 'default';

export function __setCartBehaviour(next: Behaviour) { behaviour = next; }
export function __resetCartBehaviour() { behaviour = 'default'; }

export default function CartDouble(props: CartProps) {
  if (behaviour === 'loading') return <div data-testid="cart-loading" />;
  if (behaviour === 'error') throw new Error('remote failed');
  const items = behaviour === 'empty' ? [] : props.items;
  return <div data-testid="cart-double" data-count={items.length} />;
}
// test/setup.ts — reset every double between tests, automatically
import { afterEach } from 'vitest';
import { __resetCartBehaviour } from './doubles/orders/Cart';

afterEach(() => { __resetCartBehaviour(); });

The automatic reset matters more than it looks. Module-level state in a double persists across tests in the same file, and a forgotten reset produces failures in whichever test happens to run next — the most confusing kind of flake.

The error behaviour is worth having on every double. Testing that the host degrades when a remote throws is a scenario that is otherwise almost impossible to exercise.

Step 4 — Mock the loader, not just the module #

Hosts that load remotes dynamically call a helper rather than importing a specifier, and that helper needs its own double.

// test/doubles/load-remote.ts
import { vi } from 'vitest';

const registry = new Map<string, unknown>();

export function __registerRemote(key: string, module: unknown) {
  registry.set(key, module);
}

export const loadRemote = vi.fn(async (name: string, module: string) => {
  const found = registry.get(`${name}${module}`);
  // An unregistered remote should fail the way the real loader fails.
  if (!found) throw new Error(`unknown remote: ${name}${module}`);
  return found;
});

Throwing for an unregistered remote is deliberate. A version that returned undefined would let a test pass while the host silently rendered nothing, which is exactly the production bug you are trying to catch.

Step 5 — Assert the host handles a failing remote #

The most valuable tests in a federated host are not the happy paths. They are the ones that prove one remote’s failure does not take the page with it.

// test/checkout-page.spec.tsx
import { render, screen } from '@testing-library/react';
import { __setCartBehaviour } from './doubles/orders/Cart';
import { CheckoutPage } from '../src/CheckoutPage';

it('renders the rest of the page when the cart remote throws', () => {
  __setCartBehaviour('error');
  render(<CheckoutPage />);

  // The failing region degrades…
  expect(screen.queryByTestId('cart-double')).not.toBeInTheDocument();
  // …and everything around it survives.
  expect(screen.getByTestId('order-summary')).toBeInTheDocument();
  expect(screen.getByRole('navigation')).toBeInTheDocument();
});

Assert both halves. A test that only checks the failing region passes even when the error boundary is missing and the whole page is blank.

Step 6 — Fail the build when a double drifts #

Keeping doubles honest in both directions Type annotations catch a changed module and a structural check catches a new one, which together cover how a contract actually drifts. Unverified doubles Written once, never checked Remote renames a prop Suite stays green forever Break is found by users Typed and structurally checked Annotated with the published prop type tsc fails on a changed contract CI fails on a new exposed module Both drift directions covered
A green suite with stale doubles is worse than no suite: it is a false negative everyone trusts.

Typechecking catches shape drift. A structural check catches the case where a remote adds an exposed module the host has no double for.

// scripts/check-doubles.mjs — every exposed module needs a double
import { readdirSync, existsSync } from 'node:fs';

const manifest = JSON.parse(await fetchText(process.env.MANIFEST_URL));
const missing = [];

for (const [name, spec] of Object.entries(manifest)) {
  for (const exposed of spec.exposes ?? []) {
    const file = `test/doubles/${name}/${exposed.replace(/^\.\//, '')}`;
    const found = ['.tsx', '.ts', '.jsx', '.js'].some((ext) => existsSync(file + ext));
    if (!found) missing.push(`${name}${exposed}`);
  }
}

if (missing.length) {
  console.error(`::error::no test double for: ${missing.join(', ')}`);
  process.exit(1);
}

Run this alongside tsc --noEmit in the host’s CI. Together they cover both drift directions: the type check catches a changed module, and this catches a new one.

Publishing the exposes list in the manifest is what makes the check possible, and it is worth adding for this reason alone.

Step 7 — Know what these tests cannot tell you #

Doubles buy speed and determinism by removing the thing most likely to break. That trade is correct for unit tests and dangerous if it is the only layer you have.

No amount of mocking will catch a shared-dependency version mismatch, because the double is a local module and the share scope is never involved. Nothing here catches a publicPath misconfiguration, a CSP that blocks the remote origin, or a route two remotes both claim. Those failures need the real composition, which is why the composed smoke test in end-to-end testing federated apps with Playwright is a required companion rather than an optional extra.

Stating that boundary explicitly in the repository — a short note in the test README — prevents the most common misreading of a green unit suite, which is that the integration works.

Verification #

Troubleshooting #

Symptom: a test imports the real remote and times out.

Diagnosis: the specifier does not match the alias pattern — often a deep import such as orders/components/Cart. Fix: use a regular expression capturing the whole remainder, as in step 1, and assert the alias list covers every remote name.

Symptom: the double works in Vitest and not in Jest.

Diagnosis: moduleNameMapper patterns are anchored differently and do not support the same capture syntax. Fix: keep one list of remote names and generate both configurations from it rather than maintaining two regular expressions.

Symptom: a test passes alone and fails in the suite.

Diagnosis: module-level state in a double leaking between tests. Fix: reset every double in a global afterEach, as in step 3.

Symptom: doubles are green but production breaks on a renamed prop.

Diagnosis: the double is untyped, so nothing compares it to the real module. Fix: annotate it with the remote’s published type and run tsc --noEmit in CI.

Where the doubles should live #

A small organisational decision affects how long this stays maintained: whether the doubles live in the host’s repository or are published by the remote alongside its types.

Host-owned doubles are the pragmatic starting point. They need no coordination, they can be written by whoever needs them, and they can be exactly as detailed as the host’s tests require. The cost is duplication — three hosts consuming the same remote write three doubles, each drifting independently, and each discovering the same contract change separately.

Remote-published doubles invert both properties. The team that owns the module ships a reference double beside its types, updates it in the same commit as a contract change, and every consumer picks it up automatically on the next dependency bump. That is strictly better when several hosts consume the same remote, and it is unnecessary ceremony when only one does.

The useful rule is to start host-owned and move a double into the remote’s package the second time someone else needs it. Moving is cheap; predicting which remotes will gain a second consumer is not. Whichever side owns it, keep the double in a directory that mirrors the exposed module paths exactly, so the structural check in step 6 stays a simple existence test rather than a mapping table that itself needs maintaining.