End-to-End Testing Federated Apps with Playwright #
The composed page is the one thing no unit suite sees and the one thing users actually load — this page tests it with Playwright, including the failure modes that only exist because remotes are fetched at runtime.
Two things make federated end-to-end testing different from the ordinary kind. Remote loading is a network dependency, so the suite is flaky unless you control it. And the failures worth catching are structural — a remote that did not mount, a duplicate framework copy, a route two remotes claim — rather than the interaction bugs a normal suite looks for. It implements the composed layer of testing and local development workflows.
Prerequisites #
- Playwright 1.47+ and a preview deployment where the host resolves real remotes.
- Every remote rendering a stable hook the test can find —
data-remote="<name>"on its root element. - The host’s resolved remote map serialised into the page, so tests can read which versions loaded.
- Remote entries served from predictable URLs, so route interception can match them.
Step 1 — Write the smoke test that matters most #
Before any journey test, assert that every route mounts the remotes it is supposed to. This single test catches more real federated failures than everything else combined.
// e2e/mounted.spec.js
import { test, expect } from '@playwright/test';
const ROUTES = {
'/': ['header', 'hero'],
'/browse': ['header', 'catalog'],
'/checkout': ['header', 'cart', 'summary'],
'/account': ['header', 'account'],
};
for (const [route, remotes] of Object.entries(ROUTES)) {
test(`${route} mounts ${remotes.join(', ')}`, async ({ page }) => {
const errors = [];
page.on('pageerror', (e) => errors.push(String(e)));
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
await page.goto(route);
for (const name of remotes) {
await expect(page.locator(`[data-remote="${name}"]`)).toBeVisible({ timeout: 10_000 });
}
expect(errors, errors.join('\n')).toHaveLength(0);
});
}
Every category of federation failure — a version mismatch, a broken publicPath, a blocked origin, a missing export — ends with a remote that did not mount. Asserting on that one symptom covers all of them.
Step 2 — Assert there is exactly one copy of each shared dependency #
Duplication is the most expensive federated regression and is invisible to every other test. It is trivial to check in a browser.
// e2e/singletons.spec.js
test('one React instance across the composed page', async ({ page }) => {
await page.goto('/checkout');
await expect(page.locator('[data-remote="cart"]')).toBeVisible();
const copies = await page.evaluate(() => {
const scope = window.__webpack_share_scopes__?.default ?? {};
return Object.fromEntries(
['react', 'react-dom', 'react-router-dom']
.map((dep) => [dep, Object.keys(scope[dep] ?? {})]),
);
});
for (const [dep, versions] of Object.entries(copies)) {
expect(versions.length, `${dep} resolved ${versions.join(', ')}`).toBeLessThanOrEqual(1);
}
});
Reading the share scope directly is more reliable than counting network requests, because a duplicate can arrive inside a chunk rather than as its own file. Run this after a remote has mounted — the scope is populated lazily.
Step 3 — Simulate a remote outage deterministically #
Testing degradation by hoping a remote is down is not a test. page.route makes it deterministic.
// e2e/degradation.spec.js
test('the page survives a dead remote', async ({ page }) => {
// Fail every request for this remote's assets, before navigation.
await page.route('**/recommend/**', (route) => route.abort('failed'));
await page.goto('/product/42');
// The failing region degrades…
await expect(page.locator('[data-remote="recommendations"]')).toHaveCount(0);
// …and everything else still works.
await expect(page.locator('[data-remote="header"]')).toBeVisible();
await expect(page.getByRole('button', { name: 'Add to basket' })).toBeEnabled();
});
Assert both halves. A test that only checks the region disappeared passes when the whole page is blank.
Add a slow variant with route.fulfill after a delay to exercise the timeout path, which is a different code path from an outright failure and frequently untested.
Step 4 — Pin remote versions so the suite is not testing someone else’s deploy #
A suite that resolves remotes at whatever version is live will go red when another team deploys, and a suite that goes red for reasons unrelated to the change is a suite people stop reading.
// playwright.config.js — the version under test is an input, not an accident
export default defineConfig({
use: {
baseURL: process.env.PREVIEW_URL,
extraHTTPHeaders: {
// The host honours this in preview environments to pin resolution.
'x-remote-pin': process.env.REMOTE_PIN ?? '',
},
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
retries: process.env.CI ? 1 : 0,
});
One retry is the right number. Zero makes genuine network noise look like a regression; more than one hides a real intermittent failure behind a green tick.
Step 5 — Test the seams, not the interiors #
Keep the composed suite small and pointed at what only composition can prove.
// e2e/journeys.spec.js — one test per seam-crossing journey
test('adding from catalog updates the cart remote', async ({ page }) => {
await page.goto('/browse');
await page.getByRole('button', { name: 'Add to basket' }).first().click();
// The assertion crosses a remote boundary: catalog published, cart consumed.
await expect(page.locator('[data-remote="cart"] [data-testid="count"]'))
.toHaveText('1');
});
That test exercises the event bus, the shared store, or whatever mechanism connects the two remotes — none of which any single team’s suite can reach. A test asserting a button’s label, by contrast, belongs in the owning remote’s own suite where it runs in milliseconds.
A useful rule: if a test would still pass with every remote replaced by a double, it does not belong here.
Step 6 — Catch layout shift when remotes mount #
Remotes arriving at different times is the characteristic federated cause of layout instability, and it is measurable in the same suite.
// e2e/cls.spec.js
test('remotes mounting does not shift the layout', async ({ page }) => {
await page.goto('/checkout');
const cls = await page.evaluate(() => new Promise((resolve) => {
let total = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) total += entry.value;
}
}).observe({ type: 'layout-shift', buffered: true });
// Give every remote time to mount before reading the total.
setTimeout(() => resolve(total), 4000);
}));
expect(cls, `cumulative layout shift ${cls}`).toBeLessThan(0.1);
});
Run it with network throttling. At full speed remotes mount so quickly that the shift they cause is invisible, which is exactly why it reaches production.
Step 7 — Keep the suite honest about what it did not test #
A composed suite is slow, so it will always be a sample. Making the sample explicit prevents it from being mistaken for coverage.
// e2e/coverage-report.js — list every remote the suite never exercised
import { test } from '@playwright/test';
const exercised = new Set();
test.afterEach(async ({ page }) => {
for (const el of await page.locator('[data-remote]').all()) {
exercised.add(await el.getAttribute('data-remote'));
}
});
test.afterAll(async () => {
const all = Object.keys(JSON.parse(process.env.REMOTE_MANIFEST ?? '{}'));
const untested = all.filter((name) => !exercised.has(name));
if (untested.length) console.log(`::warning::remotes never mounted in e2e: ${untested.join(', ')}`);
});
Emitting this as a warning rather than a failure is deliberate. Some remotes genuinely should not be in the composed suite, and forcing coverage produces tests written to satisfy a number. What the warning prevents is a remote silently dropping out of the suite when a route changes.
Verification #
- The smoke test fails correctly: temporarily break one remote’s entry URL and confirm the relevant route goes red with a clear message.
- Duplication is detected: remove
singleton: truefrom one remote and confirm the share-scope assertion fails. - Outage simulation works: confirm the aborted-route test passes with the remote’s requests genuinely failing in the trace.
- The suite is stable: run it ten times against an unchanged preview. Ten greens, or you have a flake to fix before trusting it.
- Traces are useful: force a failure and confirm the retained trace shows the failed remote request.
Troubleshooting #
Symptom: tests are flaky, failing on different remotes each run.
Diagnosis: the suite races remote loading. Fix: assert on visibility with a generous timeout rather than on a fixed wait, and never assert immediately after goto.
Symptom: the suite goes red when another team deploys.
Diagnosis: remotes resolve to whatever is currently live. Fix: pin resolution for the test run as in step 4, and keep a separate unpinned nightly run for early warning.
Symptom: __webpack_share_scopes__ is undefined in the page context.
Diagnosis: the global is not exposed in the production build, or no remote has loaded yet. Fix: expose it in preview builds only, and assert after a remote has mounted.
Symptom: layout shift passes in CI and fails for users.
Diagnosis: CI is fast enough that remotes mount before first paint. Fix: throttle the network in the test context so remote arrival is spread out the way it is on a real connection.
Where this suite should run, and who owns it #
A composed suite has an owner problem that ordinary end-to-end suites do not. It exercises code from several teams, it fails for reasons any of them might have caused, and it lives in one repository — usually the host’s. Left unaddressed, that produces the familiar pattern where the platform team maintains a suite that other teams break and nobody else reads.
The arrangement that works is to run the suite in both directions. It runs in the host’s pipeline on every change, which is uncontroversial. It also runs in each remote’s pipeline against a preview of that remote, gating the remote’s publish step. A breaking change then fails in front of the person who wrote it, in a build they are already watching, before anything reaches the CDN.
That requires the suite to be portable — no assumptions about which repository it was checked out into, all environment specifics supplied through configuration, and a preview URL as its only real input. Keeping it in a small package that both sides depend on is more maintenance than a single directory but much less than the alternative, which is each team writing its own partial version.
Ownership of the suite’s content should follow the seam. A journey that crosses catalog and cart belongs to whichever team owns the interaction, not to the platform team by default. The platform team owns the harness, the fixtures and the preview infrastructure — the parts that are genuinely shared.