Micro-Frontend CI/CD Pipeline Testing Strategies

Micro-Frontend CI/CD Pipeline Testing Strategies

Micro-frontends enable independent deployment — teams can release new versions of their micro-frontend without coordinating with other teams. But this independence creates CI/CD testing challenges: how do you test a remote in isolation, how do you verify the composition still works after a remote deploys, and how do you roll back safely when a remote breaks the shell?

This guide covers CI/CD testing strategies specifically for micro-frontend architectures.

The Deployment Problem

In a monolithic frontend, CI runs tests, builds the bundle, and deploys everything. In a micro-frontend architecture:

  • Each remote has its own CI pipeline and deploy cadence
  • The shell app fetches remote bundles at runtime from remote URLs
  • A remote can deploy and immediately affect production — without the shell team knowing
  • A rollback of the remote is invisible to the shell unless you have monitoring

Your CI/CD strategy must account for independent deployments while maintaining system integrity.

Stage 1: Per-Remote CI Pipeline

Each micro-frontend has its own CI that runs independently:

# product-widget/.github/workflows/ci.yml
name: Product Widget CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test-and-build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Install dependencies
        run: npm ci
      
      - name: Unit tests
        run: npm test -- --coverage
      
      - name: Contract tests (verify we satisfy shell's contract)
        run: npm run test:contracts
      
      - name: Type check
        run: npm run typecheck
      
      - name: Build
        run: npm run build
      
      - name: Bundle size check
        run: npx bundlesize
      
      - name: Deploy to staging
        if: github.ref == 'refs/heads/main'
        run: npm run deploy:staging
        env:
          DEPLOY_TARGET: https://staging-cdn.example.com/product-widget/
      
      - name: Notify composition pipeline
        if: github.ref == 'refs/heads/main'
        uses: peter-evans/repository-dispatch@v3
        with:
          token: ${{ secrets.COMPOSITION_PIPELINE_TOKEN }}
          repository: myorg/shell-app
          event-type: remote-deployed
          client-payload: '{"remote": "product-widget", "url": "https://staging-cdn.example.com/product-widget/remoteEntry.js"}'

Stage 2: Composition Testing

After any remote deploys to staging, trigger composition tests — E2E tests of the full composed application:

# shell-app/.github/workflows/composition-test.yml
name: Composition Tests
on:
  repository_dispatch:
    types: [remote-deployed]
  schedule:
    - cron: '*/30 * * * *'  # Also run every 30 minutes

jobs:
  composition-e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Install Playwright
        run: npx playwright install chromium --with-deps
      
      - name: Run composition E2E tests
        run: npx playwright test e2e/composition/
        env:
          BASE_URL: https://staging.example.com
          TRIGGERED_BY: ${{ github.event.client_payload.remote }}
      
      - name: Comment on PR if triggered by remote deploy
        if: failure() && github.event.client_payload.remote
        run: |
          echo "Composition tests failed after ${{ github.event.client_payload.remote }} deployed"
          # Notify the remote team's Slack channel or create an incident

The E2E tests in e2e/composition/ cover the critical paths of the composed application:

// e2e/composition/critical-paths.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Critical composition paths', () => {
  test('product browsing and add to cart', async ({ page }) => {
    await page.goto('/products');
    
    // Product widget (remote) must load
    await expect(page.locator('[data-testid="product-grid"]')).toBeVisible({ timeout: 10_000 });
    
    // Add a product
    await page.locator('[data-testid="product-card"]').first().locator('[data-testid="add-to-cart"]').click();
    
    // Cart count in shell header must update
    await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1', { timeout: 5_000 });
    
    // Navigate to cart widget (different remote)
    await page.goto('/cart');
    await expect(page.locator('[data-testid="cart-item"]')).toHaveCount(1);
  });

  test('checkout flow crosses multiple micro-frontends', async ({ page, context }) => {
    // Restore auth state
    await context.addCookies([{ name: 'session', value: 'test-session', domain: 'localhost' }]);
    
    await page.goto('/cart');
    await page.click('[data-testid="checkout-btn"]');
    
    // Checkout widget loads
    await expect(page.locator('[data-testid="checkout-form"]')).toBeVisible({ timeout: 8_000 });
    
    // Payment widget loads within checkout
    await expect(page.locator('[data-testid="payment-widget"]')).toBeVisible({ timeout: 8_000 });
  });
});

Testing Remote Version Compatibility

When multiple versions of a remote might be live (canary deployments, A/B tests), verify both work:

// e2e/version-compat.spec.ts
import { test, expect } from '@playwright/test';

const REMOTE_VERSIONS = [
  { name: 'stable', url: 'https://cdn.example.com/product-widget@1.5.0/remoteEntry.js' },
  { name: 'canary', url: 'https://cdn.example.com/product-widget@1.6.0-rc.1/remoteEntry.js' },
];

for (const version of REMOTE_VERSIONS) {
  test(`product widget ${version.name} works in shell`, async ({ page }) => {
    // Override the remote URL for this test
    await page.addInitScript(`
      window.__REMOTE_OVERRIDES__ = {
        'product-widget': '${version.url}'
      };
    `);
    
    await page.goto('/products');
    
    await expect(page.locator('[data-testid="product-grid"]')).toBeVisible({ timeout: 10_000 });
    
    // Core functionality must work regardless of version
    await expect(page.locator('[data-testid="product-card"]').first()).toBeVisible();
  });
}

Rollback Testing

Test that the rollback procedure works before you need it in production:

# .github/workflows/rollback-test.yml
name: Rollback Validation
on:
  workflow_dispatch:
    inputs:
      remote:
        description: 'Remote to test rollback for'
        required: true
      previous_version:
        description: 'Version to roll back to'
        required: true

jobs:
  validate-rollback:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy previous version to staging
        run: |
          npm run deploy:staging \
            --remote=${{ inputs.remote }} \
            --version=${{ inputs.previous_version }}
      
      - name: Run composition tests with rolled-back version
        run: npx playwright test e2e/composition/
        env:
          BASE_URL: https://staging.example.com
      
      - name: Verify rollback is valid
        run: |
          echo "Rollback of ${{ inputs.remote }} to ${{ inputs.previous_version }} is verified."

Bundle Size Monitoring

Micro-frontends can silently grow. Add bundle size checks to prevent performance regression:

// bundlesize.config.js
module.exports = {
  files: [
    {
      path: './dist/remoteEntry.js',
      maxSize: '50 kB',
    },
    {
      path: './dist/product-card.*.js',
      maxSize: '80 kB',
    },
    {
      path: './dist/*.css',
      maxSize: '20 kB',
    },
  ],
};

Smoke Tests After Production Deploy

Run fast smoke tests immediately after a remote deploys to production:

// smoke-tests/product-widget.smoke.ts
import { test, expect } from '@playwright/test';

const PROD_URL = process.env.PROD_URL || 'https://example.com';
const TIMEOUT = 5_000;

test.describe('Product Widget smoke tests', () => {
  test.setTimeout(30_000);

  test('remote entry is accessible', async ({ request }) => {
    const response = await request.get(`${PROD_URL}/product-widget/remoteEntry.js`);
    expect(response.status()).toBe(200);
    expect(response.headers()['content-type']).toContain('javascript');
  });

  test('product card renders in production', async ({ page }) => {
    await page.goto(`${PROD_URL}/products`);
    
    const productCard = page.locator('[data-testid="product-card"]').first();
    await expect(productCard).toBeVisible({ timeout: TIMEOUT });
  });

  test('no JS errors on product page', async ({ page }) => {
    const errors: string[] = [];
    page.on('pageerror', (err) => errors.push(err.message));
    
    await page.goto(`${PROD_URL}/products`);
    await page.waitForLoadState('networkidle');
    
    expect(errors).toHaveLength(0);
  });
});

Trigger these in CI after each production deploy:

- name: Run production smoke tests
  run: npx playwright test smoke-tests/
  env:
    PROD_URL: https://example.com
  timeout-minutes: 5

Summary

CI/CD testing for micro-frontends requires:

  • Per-remote CI: Unit tests, contract tests, build, and bundle size checks on every commit
  • Composition testing: E2E tests of the full composed application triggered after any remote deploys
  • Version compatibility tests: Verify both stable and canary versions work in the shell
  • Rollback validation: Test rollback procedures before incidents, not during
  • Production smoke tests: Fast health checks immediately after every production deploy

The critical rule: a remote team's CI should never be "done" after their own unit tests pass. The contract tests (proving they satisfy the shell's expectations) and composition tests (proving the integrated system still works) are part of every remote team's definition of done.

Read more