Single-SPA Micro-Frontend Integration Testing
Single-SPA is a framework for orchestrating multiple micro-frontend applications on a single page. Each micro-frontend registers as an application with its own lifecycle hooks — bootstrap, mount, and unmount — and Single-SPA activates them based on URL patterns.
Testing Single-SPA applications requires testing at three levels: individual micro-frontend lifecycle hooks, the root-config orchestration logic, and the integrated system.
Single-SPA Architecture Overview
Root Config (orchestrator)
├── Registers applications with activity functions
├── Manages routing — decides which apps are active
└── Handles lifecycle errors
Micro-Frontend Apps (registered applications)
├── bootstrap() — runs once on first activation
├── mount(props) — called when URL matches activity function
└── unmount() — called when navigating awayUnderstanding this model is essential for testing: lifecycle hooks are async functions, props are passed from root to apps, and routing determines which apps are active.
Testing Lifecycle Hooks
Each Single-SPA application exports bootstrap, mount, and unmount functions. Test these directly:
// product-app/src/product-app.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
let root = null;
export function bootstrap() {
return Promise.resolve();
}
export function mount(props) {
return new Promise((resolve) => {
const container = document.getElementById(props.domElementGetter());
root = createRoot(container);
root.render(<App customProps={props} />);
resolve();
});
}
export function unmount(props) {
return new Promise((resolve) => {
root.unmount();
root = null;
resolve();
});
}// product-app/src/product-app.test.js
import { bootstrap, mount, unmount } from './product-app';
import { createRoot } from 'react-dom/client';
vi.mock('react-dom/client');
describe('Product App lifecycle', () => {
let container;
let mockRoot;
beforeEach(() => {
container = document.createElement('div');
container.id = 'product-app-container';
document.body.appendChild(container);
mockRoot = { render: vi.fn(), unmount: vi.fn() };
vi.mocked(createRoot).mockReturnValue(mockRoot);
});
afterEach(() => {
document.body.removeChild(container);
});
const mockProps = {
domElementGetter: () => 'product-app-container',
name: 'product-app',
mountParcel: vi.fn(),
singleSpa: {},
};
it('bootstrap resolves successfully', async () => {
await expect(bootstrap(mockProps)).resolves.toBeUndefined();
});
it('mount renders the React app into the container', async () => {
await mount(mockProps);
expect(createRoot).toHaveBeenCalledWith(container);
expect(mockRoot.render).toHaveBeenCalledTimes(1);
});
it('unmount calls root.unmount()', async () => {
await mount(mockProps);
await unmount(mockProps);
expect(mockRoot.unmount).toHaveBeenCalledTimes(1);
});
it('mount passes custom props to the App component', async () => {
const propsWithCustomData = {
...mockProps,
userId: 'user-123',
theme: 'dark',
};
await mount(propsWithCustomData);
// Verify the component received the custom props
const renderCall = mockRoot.render.mock.calls[0][0];
expect(renderCall.props.customProps.userId).toBe('user-123');
});
});Testing Activity Functions
Activity functions determine when an app is active based on the current URL. These are pure functions and easy to test:
// root-config/src/activity-functions.js
export function productAppIsActive(location) {
return location.pathname.startsWith('/products');
}
export function cartAppIsActive(location) {
return location.pathname.startsWith('/cart');
}
export function authAppIsActive(location) {
return ['/login', '/register', '/forgot-password'].includes(location.pathname);
}
export function dashboardAppIsActive(location) {
// Active for authenticated routes except specific sub-apps
return (
location.pathname.startsWith('/dashboard') &&
!location.pathname.startsWith('/dashboard/reports')
);
}// root-config/src/activity-functions.test.js
import { productAppIsActive, cartAppIsActive, authAppIsActive, dashboardAppIsActive } from './activity-functions';
describe('Activity functions', () => {
describe('productAppIsActive', () => {
it.each([
['/products', true],
['/products/1', true],
['/products/search?q=widget', true],
['/', false],
['/cart', false],
['/dashboard', false],
])('returns %s for path %s', (path, expected) => {
const location = { pathname: path };
expect(productAppIsActive(location)).toBe(expected);
});
});
describe('authAppIsActive', () => {
it.each([
['/login', true],
['/register', true],
['/forgot-password', true],
['/dashboard', false],
['/products', false],
])('returns %s for path %s', (path, expected) => {
expect(authAppIsActive({ pathname: path })).toBe(expected);
});
});
describe('dashboardAppIsActive', () => {
it('is active for /dashboard', () => {
expect(dashboardAppIsActive({ pathname: '/dashboard' })).toBe(true);
});
it('is active for dashboard sub-routes', () => {
expect(dashboardAppIsActive({ pathname: '/dashboard/settings' })).toBe(true);
});
it('is NOT active for /dashboard/reports (handled by reports app)', () => {
expect(dashboardAppIsActive({ pathname: '/dashboard/reports' })).toBe(false);
});
});
});Testing Root Config Registration
The root config registers apps. Test that registration is correct:
// root-config/src/root-config.js
import { registerApplication, start } from 'single-spa';
export function initializeRootConfig() {
registerApplication({
name: 'product-app',
app: () => import('product-app/product-app'),
activeWhen: (location) => location.pathname.startsWith('/products'),
});
registerApplication({
name: 'cart-app',
app: () => import('cart-app/cart-app'),
activeWhen: '/cart',
customProps: {
apiUrl: process.env.CART_API_URL,
},
});
start();
}// root-config/src/root-config.test.js
import { registerApplication, start } from 'single-spa';
import { initializeRootConfig } from './root-config';
vi.mock('single-spa', () => ({
registerApplication: vi.fn(),
start: vi.fn(),
}));
// Mock the federated module imports
vi.mock('product-app/product-app', () => ({ default: {} }));
vi.mock('cart-app/cart-app', () => ({ default: {} }));
describe('Root config initialization', () => {
beforeEach(() => {
vi.clearAllMocks();
initializeRootConfig();
});
it('registers all expected applications', () => {
expect(registerApplication).toHaveBeenCalledTimes(2);
const registeredNames = vi.mocked(registerApplication).mock.calls.map(
([config]) => config.name
);
expect(registeredNames).toContain('product-app');
expect(registeredNames).toContain('cart-app');
});
it('cart app receives API URL as custom prop', () => {
const cartRegistration = vi.mocked(registerApplication).mock.calls.find(
([config]) => config.name === 'cart-app'
)[0];
expect(cartRegistration.customProps).toHaveProperty('apiUrl');
});
it('calls start() after registering all apps', () => {
expect(start).toHaveBeenCalledTimes(1);
});
});Testing Cross-App Communication
Single-SPA apps often communicate via custom events or a shared event bus:
// shared/event-bus.js
const eventBus = new EventTarget();
export function publishEvent(type, detail) {
eventBus.dispatchEvent(new CustomEvent(type, { detail }));
}
export function subscribeToEvent(type, handler) {
eventBus.addEventListener(type, (event) => handler(event.detail));
return () => eventBus.removeEventListener(type, handler);
}// tests/event-bus.test.js
import { publishEvent, subscribeToEvent } from '../shared/event-bus';
describe('Cross-app event bus', () => {
it('subscriber receives event from publisher', () => {
const handler = vi.fn();
const unsubscribe = subscribeToEvent('cart:item-added', handler);
publishEvent('cart:item-added', { productId: 1, quantity: 2 });
expect(handler).toHaveBeenCalledWith({ productId: 1, quantity: 2 });
unsubscribe();
});
it('unsubscribed handler does not receive events', () => {
const handler = vi.fn();
const unsubscribe = subscribeToEvent('cart:item-added', handler);
unsubscribe();
publishEvent('cart:item-added', { productId: 1, quantity: 1 });
expect(handler).not.toHaveBeenCalled();
});
it('multiple subscribers receive the same event', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
subscribeToEvent('user:login', handler1);
subscribeToEvent('user:login', handler2);
publishEvent('user:login', { userId: 'u1' });
expect(handler1).toHaveBeenCalledWith({ userId: 'u1' });
expect(handler2).toHaveBeenCalledWith({ userId: 'u1' });
});
});E2E Testing the Single-SPA Application
// e2e/routing.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Single-SPA routing', () => {
test('product app mounts when navigating to /products', async ({ page }) => {
await page.goto('/');
await page.click('a[href="/products"]');
await expect(page).toHaveURL('/products');
await expect(page.locator('[data-testid="product-app-root"]')).toBeVisible();
});
test('cart app mounts when navigating to /cart', async ({ page }) => {
await page.goto('/products');
await page.click('a[href="/cart"]');
await expect(page).toHaveURL('/cart');
await expect(page.locator('[data-testid="cart-app-root"]')).toBeVisible();
});
test('product app unmounts when navigating away', async ({ page }) => {
await page.goto('/products');
// Verify product app is mounted
await expect(page.locator('[data-testid="product-app-root"]')).toBeVisible();
// Navigate away
await page.goto('/cart');
// Product app should unmount
await expect(page.locator('[data-testid="product-app-root"]')).not.toBeVisible();
await expect(page.locator('[data-testid="cart-app-root"]')).toBeVisible();
});
test('cross-app cart count updates after adding product', async ({ page }) => {
await page.goto('/products');
// Add a product from the product app
await page.click('[data-testid="add-to-cart-btn"]');
// Cart count in the shell header (not the cart app) should update
await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');
});
});Summary
Single-SPA micro-frontend testing layers:
- Unit test lifecycle hooks (
bootstrap,mount,unmount) with mocked ReactDOM - Unit test activity functions — pure functions with path → boolean assertions
- Unit test root config — verify all apps are registered with correct names and props
- Test cross-app communication — event bus subscription and publishing
- E2E test routing — app mounts/unmounts on navigation, state updates across apps
Single-SPA's explicit lifecycle API makes lifecycle hooks highly testable in isolation. Focus integration testing effort on the routing logic and cross-app communication, as these are where micro-frontend bugs most commonly hide.