E2E Testing Micro-Frontend Composition with Playwright

E2E Testing Micro-Frontend Composition with Playwright

End-to-end tests for micro-frontends are harder to set up than for monolithic apps, but they're not optional. Unit tests and contract tests verify pieces in isolation. E2E tests verify that everything works together when a real user navigates through the composed application — crossing remote boundaries, persisting auth state, and triggering cross-MFE flows.

This post covers the complete Playwright setup for MFE E2E testing: starting multiple servers, writing cross-MFE user flows, handling authentication, testing version-skew scenarios, and making it fast enough to run in CI.

Starting Multiple MFE Servers

The first challenge is the test environment. Your E2E tests need all the remotes running. Playwright's globalSetup is the right place to start them:

// playwright/globalSetup.ts
import { spawn, ChildProcess } from 'child_process';
import waitOn from 'wait-on';

interface Server {
  process: ChildProcess;
  url: string;
  name: string;
}

const servers: Server[] = [
  { name: 'shell', url: 'http://localhost:3000', process: null },
  { name: 'header-remote', url: 'http://localhost:3001', process: null },
  { name: 'products-remote', url: 'http://localhost:3002', process: null },
  { name: 'cart-remote', url: 'http://localhost:3003', process: null },
  { name: 'checkout-remote', url: 'http://localhost:3004', process: null },
];

export default async function globalSetup() {
  const startedServers: ChildProcess[] = [];
  
  for (const server of servers) {
    const proc = spawn('npm', ['run', 'start:test'], {
      cwd: `../../${server.name}`,
      stdio: 'pipe',
      env: {
        ...process.env,
        PORT: new URL(server.url).port,
        NODE_ENV: 'test',
      }
    });
    
    startedServers.push(proc);
    
    proc.stderr.on('data', (data) => {
      if (process.env.DEBUG_SERVERS) {
        console.error(`[${server.name}] ${data}`);
      }
    });
  }
  
  // Store for teardown
  (global as any).__MFE_SERVERS__ = startedServers;
  
  // Wait for all servers to respond
  await waitOn({
    resources: servers.map(s => s.url),
    timeout: 60000,
    interval: 500,
  });
  
  console.log('All MFE servers ready');
}
// playwright/globalTeardown.ts
export default async function globalTeardown() {
  const servers: ChildProcess[] = (global as any).__MFE_SERVERS__ || [];
  servers.forEach(proc => proc.kill('SIGTERM'));
}
// playwright.config.ts
export default defineConfig({
  globalSetup: './playwright/globalSetup.ts',
  globalTeardown: './playwright/globalTeardown.ts',
  use: {
    baseURL: 'http://localhost:3000',
  },
  webServer: undefined, // We manage servers in globalSetup
});

For CI where you want to use pre-built artifacts instead of running dev servers:

// playwright/globalSetup.ci.ts
export default async function globalSetup() {
  // In CI, use serve instead of dev server
  for (const server of servers) {
    spawn('npx', ['serve', '-s', 'dist', '-l', new URL(server.url).port], {
      cwd: `../../${server.name}/dist`,
    });
  }
  
  await waitOn({ resources: servers.map(s => s.url), timeout: 30000 });
}

Testing Cross-MFE User Flows

The whole point of E2E tests in MFEs is testing flows that cross remote boundaries. A user who browses products (products remote), adds to cart (cart remote), and checks out (checkout remote) is the canonical cross-MFE flow.

// tests/e2e/purchaseFlow.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Purchase flow', () => {
  test('user can browse, add to cart, and complete checkout', async ({ page }) => {
    // Step 1: Land on homepage (shell + header remote)
    await page.goto('/');
    await expect(page.locator('[data-mfe="header"]')).toBeVisible();
    
    // Step 2: Navigate to products (products remote)
    await page.click('[data-testid="nav-products"]');
    await page.waitForURL('**/products');
    await expect(page.locator('[data-mfe="products"]')).toBeVisible();
    
    // Step 3: Add item to cart
    const firstProduct = page.locator('[data-testid="product-card"]').first();
    const productName = await firstProduct.locator('[data-testid="product-name"]').textContent();
    await firstProduct.locator('[data-testid="add-to-cart"]').click();
    
    // Step 4: Verify cart badge updated (header remote reflects products remote action)
    await expect(page.locator('[data-testid="cart-badge"]')).toHaveText('1');
    
    // Step 5: Open cart (cart remote)
    await page.click('[data-testid="cart-icon"]');
    await expect(page.locator('[data-mfe="cart"]')).toBeVisible();
    await expect(page.locator('[data-testid="cart-item-name"]')).toHaveText(productName);
    
    // Step 6: Proceed to checkout (checkout remote)
    await page.click('[data-testid="checkout-button"]');
    await page.waitForURL('**/checkout');
    await expect(page.locator('[data-mfe="checkout"]')).toBeVisible();
    
    // Step 7: Verify order summary shows correct item
    await expect(page.locator('[data-testid="order-summary-item"]')).toContainText(productName);
    
    // Step 8: Complete checkout
    await page.fill('[data-testid="email-input"]', 'test@example.com');
    await page.fill('[data-testid="card-number"]', '4242424242424242');
    await page.fill('[data-testid="card-expiry"]', '12/26');
    await page.fill('[data-testid="card-cvv"]', '123');
    
    await page.click('[data-testid="place-order"]');
    
    // Step 9: Verify confirmation (shell + order-confirmation remote)
    await page.waitForURL('**/order-confirmation/**');
    await expect(page.locator('[data-testid="confirmation-message"]')).toBeVisible();
    await expect(page.locator('[data-testid="cart-badge"]')).toHaveText('0');
  });
});

Mark each remote's root element with data-mfe attributes. This lets your E2E tests explicitly verify that the right remote rendered for each step, not just that some content appeared.

Authentication State Across MFEs

Auth is one of the trickiest cross-MFE concerns. Each remote needs to know the user's auth state. Test that auth is properly propagated:

// tests/e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

// Create auth state once and reuse across tests
setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[data-testid="email"]', 'testuser@example.com');
  await page.fill('[data-testid="password"]', 'testpassword');
  await page.click('[data-testid="login-button"]');
  
  await page.waitForURL('**/dashboard');
  await expect(page.locator('[data-testid="user-name"]')).toBeVisible();
  
  // Save auth state (cookies, localStorage) for reuse
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
// playwright.config.ts
export default defineConfig({
  projects: [
    {
      name: 'setup',
      testMatch: /auth\.setup\.ts/,
    },
    {
      name: 'authenticated',
      use: {
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'unauthenticated',
      // No storage state — tests run without auth
    }
  ]
});

Then test auth propagation explicitly:

// tests/e2e/authPropagation.spec.ts
test.use({ storageState: 'playwright/.auth/user.json' });

test('auth state is visible across all remotes', async ({ page }) => {
  await page.goto('/dashboard');
  
  // Header remote should show logged-in user
  await expect(page.locator('[data-mfe="header"] [data-testid="user-name"]'))
    .toHaveText('Test User');
  
  // Sidebar remote should show personalized nav
  await expect(page.locator('[data-mfe="sidebar"] [data-testid="user-settings-link"]'))
    .toBeVisible();
  
  // Navigate to profile page (profile remote)
  await page.goto('/profile');
  await expect(page.locator('[data-mfe="profile"] [data-testid="profile-email"]'))
    .toHaveText('testuser@example.com');
});

test('logout invalidates session across all remotes', async ({ page }) => {
  await page.goto('/');
  
  await page.click('[data-testid="logout-button"]');
  
  await page.waitForURL('**/login');
  
  // Navigate to protected route
  await page.goto('/dashboard');
  
  // Should redirect back to login, not show dashboard
  await expect(page).toHaveURL(/\/login/);
  
  // Header should no longer show user name
  await expect(page.locator('[data-testid="user-name"]')).not.toBeVisible();
});

Handling Version-Skew Scenarios

In production, remotes can be at different versions. A deployment in progress may have some servers running new code and some running old code. Test that the shell handles this gracefully:

// tests/e2e/versionSkew.spec.ts
test('shell handles unavailable remote gracefully', async ({ page }) => {
  // Simulate a remote being temporarily unavailable (deployment in progress)
  await page.route('http://localhost:3003/**', route => route.abort());
  
  await page.goto('/');
  
  // Shell should still load
  await expect(page.locator('[data-mfe="header"]')).toBeVisible();
  await expect(page.locator('[data-mfe="products"]')).toBeVisible();
  
  // Cart remote (unavailable) should show fallback, not crash the page
  await page.click('[data-testid="cart-icon"]');
  await expect(page.locator('[data-testid="remote-error-fallback"]'))
    .toHaveText('Cart temporarily unavailable');
  
  // User can still navigate to other sections
  await page.click('[data-testid="nav-products"]');
  await expect(page.locator('[data-mfe="products"]')).toBeVisible();
});

test('shell handles remote returning 500 error', async ({ page }) => {
  await page.route('http://localhost:3003/remoteEntry.js', route => {
    route.fulfill({ status: 500, body: 'Internal Server Error' });
  });
  
  await page.goto('/');
  
  // Shell loads without crashing
  await expect(page.locator('body')).not.toContainText('Application Error');
  
  // Error boundary for cart remote shows
  await page.click('[data-testid="cart-icon"]');
  await expect(page.locator('[data-testid="cart-error-boundary"]')).toBeVisible();
});

test('shell handles slow remote loading', async ({ page }) => {
  // Simulate a slow remote (2 second delay)
  await page.route('http://localhost:3004/**', async route => {
    await new Promise(resolve => setTimeout(resolve, 2000));
    route.continue();
  });
  
  await page.goto('/checkout');
  
  // Loading indicator should show while checkout remote loads
  await expect(page.locator('[data-testid="remote-loading"]')).toBeVisible();
  
  // Eventually, checkout renders
  await expect(page.locator('[data-mfe="checkout"]')).toBeVisible({ timeout: 10000 });
  
  // Loading indicator gone
  await expect(page.locator('[data-testid="remote-loading"]')).not.toBeVisible();
});

CI Parallel Execution

Running E2E tests across multiple MFE servers in CI takes time. Playwright's sharding distributes tests across multiple machines:

# .github/workflows/e2e.yml
jobs:
  e2e:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Build all MFEs
        run: |
          npm run build --workspace=header-remote
          npm run build --workspace=products-remote
          npm run build --workspace=cart-remote
          npm run build --workspace=checkout-remote
          npm run build --workspace=shell
      
      - name: Install Playwright
        run: npx playwright install --with-deps chromium
      
      - name: Run E2E tests (shard ${{ matrix.shard }}/4)
        run: npx playwright test --shard=${{ matrix.shard }}/4
        env:
          CI: true
          USE_BUILT_ARTIFACTS: true
      
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: playwright-report-shard-${{ matrix.shard }}
          path: playwright-report/

  merge-reports:
    needs: e2e
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v3
        with:
          pattern: playwright-report-shard-*
          merge-multiple: true
          path: playwright-reports/
      
      - name: Merge reports
        run: npx playwright merge-reports --reporter html ./playwright-reports
      
      - uses: actions/upload-artifact@v3
        with:
          name: playwright-report
          path: playwright-report/

For parallelizing at the test file level within a single machine, group by remote:

// playwright.config.ts
export default defineConfig({
  workers: process.env.CI ? 2 : 4,
  // Group tests so related tests run together
  projects: [
    {
      name: 'products-flows',
      testMatch: '**/products/**/*.spec.ts',
    },
    {
      name: 'checkout-flows',
      testMatch: '**/checkout/**/*.spec.ts',
    },
    {
      name: 'auth-flows',
      testMatch: '**/auth/**/*.spec.ts',
    },
    {
      name: 'shell-composition',
      testMatch: '**/shell/**/*.spec.ts',
    },
  ]
});

Test Data Management

E2E tests need consistent test data. In an MFE architecture, different remotes may call different backend services. Coordinate your test data strategy:

// playwright/testData.ts
export async function seedTestData() {
  // Seed through the API, not directly to the DB
  await fetch('http://localhost:4000/test/seed', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      users: [{ email: 'testuser@example.com', password: 'testpassword' }],
      products: require('./fixtures/products.json'),
      inventory: require('./fixtures/inventory.json'),
    })
  });
}

export async function cleanupTestData() {
  await fetch('http://localhost:4000/test/cleanup', { method: 'POST' });
}

Use test-specific user accounts or database transactions that roll back after each test. Don't rely on production data or shared test accounts — flaky test data causes flaky tests.

What Makes a Good MFE E2E Test

A good MFE E2E test:

  1. Crosses at least one remote boundary — otherwise it's not testing MFE composition
  2. Verifies the user's goal — not intermediate implementation details
  3. Uses data-mfe attributes to verify the right remote rendered
  4. Handles async loading — remotes load asynchronously, tests must wait properly
  5. Has a clear failure message — when it fails, you know which remote and which step

Avoid testing things that unit or contract tests already cover. E2E tests are expensive — run them on the critical user paths: registration, login/logout, the primary purchase flow, and the primary content creation flow. Everything else can be covered by lower-level tests.

The investment in the global setup infrastructure pays off quickly. Once you have multiple servers starting reliably and Playwright sharding set up in CI, adding new cross-MFE test scenarios is straightforward.

Read more

Start now free