Single-SPA Micro-Frontend Integration Testing

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 away

Understanding 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.

Read more