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 incidentThe 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: 5Summary
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.