Testing Micro-Frontends with Module Federation (Webpack 5)
Module Federation in Webpack 5 enables true micro-frontend architecture — separate teams building, deploying, and updating independent JavaScript bundles that compose into a single application at runtime. The testing challenges this introduces are real: federated modules can fail to load, version contracts can break silently, and the shell app's behavior depends on remote apps that don't exist in your test environment.
This guide covers testing strategies for micro-frontends built with Module Federation: unit testing federated modules, integration testing the shell-remote boundary, and E2E testing the composed application.
Understanding What Can Break
Before testing, understand the failure modes specific to Module Federation:
Remote module fails to load: The remote app is down, the URL is wrong, or the bundle has a chunk hash mismatch. The shell app sees a network error and must handle it gracefully.
Shared dependency version conflict: Remote exposes a React component; shell loads a different React version. Both try to use the shared singleton — conflict at runtime.
Interface contract breaks: Remote team changes the exported module's props or API. Shell team doesn't know until runtime.
CSP violations: Remote script URLs are blocked by Content Security Policy headers.
Chunk loading timeout: Remote loads but takes too long — user sees a blank area or infinite spinner.
Testing Strategy
| Level | What to test | Tools |
|---|---|---|
| Unit | Individual federated module logic, no federation | Jest/Vitest |
| Contract | Remote's exported API matches shell's expectations | Jest + mock |
| Integration | Shell loads remote correctly, fallback behavior | Webpack DevServer + Jest |
| E2E | Full composed application user flows | Playwright |
Unit Testing Federated Modules
Federated modules are regular JavaScript — test them without Module Federation infrastructure:
// ProductWidget/src/components/ProductCard/index.jsx
export function ProductCard({ product, onAddToCart }) {
const isOutOfStock = product.stock === 0;
return (
<div data-testid="product-card" className={isOutOfStock ? 'out-of-stock' : ''}>
<h2>{product.name}</h2>
<span data-testid="price">${product.price.toFixed(2)}</span>
{isOutOfStock ? (
<span role="status">Out of stock</span>
) : (
<button onClick={() => onAddToCart(product)} disabled={isOutOfStock}>
Add to cart
</button>
)}
</div>
);
}// ProductWidget/src/components/ProductCard/ProductCard.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductCard } from './index';
const mockProduct = { id: 1, name: 'Widget Pro', price: 29.99, stock: 5 };
describe('ProductCard', () => {
it('renders product details', () => {
render(<ProductCard product={mockProduct} onAddToCart={vi.fn()} />);
expect(screen.getByText('Widget Pro')).toBeInTheDocument();
expect(screen.getByTestId('price')).toHaveTextContent('$29.99');
});
it('calls onAddToCart when button clicked', async () => {
const user = userEvent.setup();
const onAddToCart = vi.fn();
render(<ProductCard product={mockProduct} onAddToCart={onAddToCart} />);
await user.click(screen.getByRole('button', { name: /add to cart/i }));
expect(onAddToCart).toHaveBeenCalledWith(mockProduct);
});
it('shows out-of-stock state when stock is 0', () => {
const outOfStockProduct = { ...mockProduct, stock: 0 };
render(<ProductCard product={outOfStockProduct} onAddToCart={vi.fn()} />);
expect(screen.getByRole('status')).toHaveTextContent('Out of stock');
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('applies out-of-stock CSS class', () => {
const outOfStockProduct = { ...mockProduct, stock: 0 };
render(<ProductCard product={outOfStockProduct} onAddToCart={vi.fn()} />);
expect(screen.getByTestId('product-card')).toHaveClass('out-of-stock');
});
});Mocking Federated Remotes in Tests
The shell app imports from remotes like import('ProductWidget/ProductCard'). In tests, this import fails because there's no federation runtime. Mock it with Jest/Vitest module mocks:
// shell/__mocks__/ProductWidget/ProductCard.jsx
export function ProductCard({ product, onAddToCart }) {
return (
<div data-testid="mock-product-card">
<span>{product.name}</span>
<button onClick={() => onAddToCart(product)}>Add</button>
</div>
);
}
export default ProductCard;In jest.config.js:
module.exports = {
moduleNameMapper: {
'^ProductWidget/(.*)$': '<rootDir>/__mocks__/ProductWidget/$1',
'^CartWidget/(.*)$': '<rootDir>/__mocks__/CartWidget/$1',
},
};Now test the shell component with mocked remotes:
// shell/src/App/App.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import App from './App';
// Remote modules are mocked via moduleNameMapper
describe('Shell App', () => {
it('renders the product widget', async () => {
render(<App />);
// Remote loads asynchronously
await waitFor(() => {
expect(screen.getByTestId('mock-product-card')).toBeInTheDocument();
});
});
it('shows loading state while remote loads', () => {
render(<App />);
// Before lazy-loaded remote resolves, show a spinner
expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument();
});
});Testing Remote Loading Failures
The most important shell test: what happens when a remote fails to load?
// shell/src/RemoteLoader/RemoteLoader.jsx
import React, { Suspense, lazy } from 'react';
import ErrorBoundary from './ErrorBoundary';
const loadRemote = (url, scope, module) => {
return lazy(async () => {
try {
await __webpack_init_sharing__('default');
const container = window[scope];
await container.init(__webpack_share_scopes__.default);
const factory = await container.get(module);
return factory();
} catch (error) {
throw new Error(`Failed to load ${scope}/${module}: ${error.message}`);
}
});
};// shell/src/RemoteLoader/RemoteLoader.test.jsx
import { render, screen } from '@testing-library/react';
import { RemoteLoader } from './RemoteLoader';
describe('RemoteLoader', () => {
it('shows fallback when remote fails to load', async () => {
// Mock a remote that fails
window.FailingRemote = {
init: vi.fn(),
get: vi.fn().mockRejectedValue(new Error('Module not found')),
};
global.__webpack_init_sharing__ = vi.fn().mockResolvedValue(undefined);
global.__webpack_share_scopes__ = { default: {} };
render(
<RemoteLoader
url="https://remote.example.com/remoteEntry.js"
scope="FailingRemote"
module="./Widget"
fallback={<div data-testid="error-fallback">Widget unavailable</div>}
/>
);
await screen.findByTestId('error-fallback');
expect(screen.getByTestId('error-fallback')).toHaveTextContent('Widget unavailable');
});
});Testing Shared Dependencies
Module Federation shares dependencies like React, ReactDOM, and shared state libraries. Test that shared singletons don't get duplicated:
// tests/shared-deps/shared-singleton.test.js
describe('Shared dependency isolation', () => {
it('shell and remote use the same React instance', async () => {
// In a real integration test with both apps loaded:
const shellReact = window.__SHELL_REACT_INSTANCE__;
const remoteReact = window.__REMOTE_REACT_INSTANCE__;
expect(shellReact).toBe(remoteReact); // Same reference = same singleton
});
it('shared store state is visible across remotes', async () => {
// If using a shared Redux/Zustand store:
const shellStore = window.__APP_STORE__;
// Remote should read from the same store
shellStore.dispatch({ type: 'user/login', payload: { id: 'u1' } });
const remoteUserState = window.__REMOTE_USER_STATE__();
expect(remoteUserState.id).toBe('u1');
});
});E2E Testing the Composed Application
E2E tests run against the full composed application — both shell and remotes running simultaneously:
// e2e/composition.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Composed application', () => {
test('shell renders product widget from remote', async ({ page }) => {
await page.goto('http://localhost:3000');
// Wait for the federated module to load
await page.waitForSelector('[data-testid="product-card"]', { timeout: 10_000 });
// Verify the remote-rendered content
const productCard = page.locator('[data-testid="product-card"]').first();
await expect(productCard).toBeVisible();
await expect(productCard.locator('[data-testid="price"]')).toBeVisible();
});
test('shows error boundary when remote is unavailable', async ({ page }) => {
// Block the remote entry point
await page.route('**/productWidget/remoteEntry.js', (route) => {
route.abort('connectionrefused');
});
await page.goto('http://localhost:3000');
// Error boundary should show fallback UI
await expect(
page.locator('[data-testid="remote-error-fallback"]')
).toBeVisible({ timeout: 5_000 });
});
test('navigation between micro-frontends works', async ({ page }) => {
await page.goto('http://localhost:3000');
// Navigate to a route owned by a different micro-frontend
await page.click('nav a[href="/cart"]');
await expect(page).toHaveURL('/cart');
await expect(page.locator('[data-testid="cart-widget"]')).toBeVisible();
});
test('shared auth state visible across micro-frontends', async ({ page }) => {
// Log in via the auth micro-frontend
await page.goto('http://localhost:3000/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password');
await page.click('[type="submit"]');
await page.waitForURL('/dashboard');
// Auth state should be visible in the header (shell) AND in a remote widget
await expect(page.locator('[data-testid="user-greeting"]')).toBeVisible();
await expect(page.locator('[data-testid="profile-widget"]')).toContainText('test@example.com');
});
});CI Configuration
Run remotes and shell in parallel for E2E tests:
# .github/workflows/e2e.yml
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build all micro-frontends
run: npm run build:all
- name: Start micro-frontend servers in parallel
run: |
npm run serve:product-widget &
npm run serve:cart-widget &
npm run serve:shell &
npx wait-on http://localhost:3001 http://localhost:3002 http://localhost:3000
- name: Run E2E tests
run: npx playwright testSummary
Micro-frontend testing with Module Federation requires layered coverage:
- Unit tests run without federation infrastructure — test federated modules like regular React/Vue components
- Mock remotes with
moduleNameMapperso shell unit tests don't need running remote servers - Test failure paths — error boundaries, loading states, and fallback UI when remotes fail
- E2E tests validate the full composed experience — route ownership, auth state sharing, and cross-remote interactions
- Block remotes in E2E tests to verify graceful degradation
The critical rule: always test what happens when a remote is unavailable. In production, remotes fail. Your shell must handle it gracefully.