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.
Prerequisites #
- Vitest 2+ or Jest 29+ with
jsdom. - TypeScript 5.4+ if you want the doubles typechecked, which is the main thing that keeps them accurate.
- Remote types available to the host — published
.d.tsfiles or a shared types package, per sharing TypeScript types across federated remotes. - A naming convention that makes federated specifiers recognisable, such as always prefixing them with the remote’s name.
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 #
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 #
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 #
- No real remote loads: run the suite offline. If a test hangs or times out, a specifier is escaping the alias.
- Types are enforced: add a required prop to a remote’s type and confirm
tsc --noEmitfails on the double. - Doubles reset: run two tests that set different behaviours and confirm each sees its own.
- Missing doubles are caught: add an exposed module to the manifest fixture and confirm the structural check fails.
- Failure paths are covered: set a double to
errorand confirm the host renders its fallback rather than crashing.
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.