19

Module 19: Testing Micro Frontends Like a Professional

Chapter 19 • Advanced

45 min

Testing Micro Frontends Like a Professional

The Real Problem

Scenario: Products team wants to test their cart feature. They need Checkout running. Checkout team is deploying. Tests are blocked.

The Question: How do I test that Products and Checkout work together without testing everything together?

This must be opinionated, not exhaustive.


What NOT to Test

❌ Don't Test Module Federation Itself

Module Federation is a build tool. You don't test Webpack. You test your code.

Wrong:

typescript.js
it('should load remote module', async () => {
  const module = await import('products/ProductsModule')
  expect(module).toBeDefined()
})

Why: This tests Webpack, not your code. Webpack already works.


❌ Don't Test Everything Together

Wrong:

typescript.js
// E2E test that requires all apps
it('should add product to cart and checkout', async () => {
  // Start Products app
  // Start Checkout app
  // Start Host app
  // Test full flow
})

Why: Slow, flaky, requires all teams to coordinate.


❌ Don't Test Implementation Details

Wrong:

typescript.js
it('should call eventBus.emit', () => {
  const service = new CartService(mockEventBus)
  service.addItem(item)
  expect(mockEventBus.emit).toHaveBeenCalled()
})

Why: Tests implementation, not behavior. Breaks on refactor.


What TO Test

✅ Test Contracts Between Apps

Right:

typescript.js
it('should emit cart:item-added event with correct structure', () => {
  const service = new CartService(mockEventBus)
  service.addItem({ id: '1', name: 'Product', price: 10 })
  
  expect(mockEventBus.emit).toHaveBeenCalledWith(
    jasmine.objectContaining({
      event: 'cart:item-added',
      version: '1.0.0',
      data: jasmine.objectContaining({
        itemId: '1',
        price: 10
      })
    })
  )
})

Why: Tests the contract, not implementation. Contract is what matters.


Strategy 1: Contract Testing

The Pattern

Test that apps emit and handle events correctly, without running apps together.

Products App: Test Event Emission

typescript.js
// products-app/src/app/cart/cart.service.spec.ts
describe('CartService', () => {
  let service: CartService
  let mockEventBus: jasmine.SpyObj<EventBus>
  
  beforeEach(() => {
    mockEventBus = jasmine.createSpyObj('EventBus', ['emit'])
    service = new CartService(mockEventBus, mockStorage)
  })
  
  it('should emit cart:item-added event with correct contract', () => {
    const item = { id: '1', name: 'Product', price: 10, quantity: 2 }
    
    service.addItem(item)
    
    expect(mockEventBus.emit).toHaveBeenCalledWith({
      event: 'cart:item-added',
      version: '1.0.0',
      data: {
        itemId: '1',
        productId: '1',
        quantity: 2,
        price: 10
      },
      timestamp: jasmine.any(Number)
    })
  })
  
  it('should persist cart to storage', () => {
    const item = { id: '1', name: 'Product', price: 10 }
    
    service.addItem(item)
    
    expect(mockStorage.set).toHaveBeenCalledWith(
      'cart',
      jasmine.arrayContaining([jasmine.objectContaining({ id: '1' })])
    )
  })
})

Checkout App: Test Event Handling

typescript.js
// checkout-app/src/app/cart/cart.service.spec.ts
describe('CheckoutCartService', () => {
  let service: CheckoutCartService
  let mockEventBus: jasmine.SpyObj<EventBus>
  
  beforeEach(() => {
    mockEventBus = jasmine.createSpyObj('EventBus', ['on'])
    mockEventBus.on.and.returnValue(of({
      event: 'cart:item-added',
      version: '1.0.0',
      data: { itemId: '1', productId: '1', quantity: 1, price: 10 }
    }))
    
    service = new CheckoutCartService(mockEventBus, mockStorage)
  })
  
  it('should handle cart:item-added event v1.0.0', () => {
    expect(service.items$.value).toHaveLength(1)
    expect(service.items$.value[0].id).toBe('1')
  })
  
  it('should handle cart:item-added event v2.0.0', () => {
    mockEventBus.on.and.returnValue(of({
      event: 'cart:item-added',
      version: '2.0.0',
      data: { itemId: '1', productId: '1', quantity: 1, price: 10, discount: 2 }
    }))
    
    service = new CheckoutCartService(mockEventBus, mockStorage)
    
    expect(service.items$.value).toHaveLength(1)
    expect(service.items$.value[0].discount).toBe(2)
  })
})

Strategy 2: Mocking Remotes Locally

The Problem

Products team wants to test without Checkout running.

The Solution: Mock Remote Modules

typescript.js
// products-app/src/app/cart/cart.service.spec.ts
describe('CartService Integration', () => {
  it('should work with mocked Checkout', () => {
    // Mock Checkout service
    const mockCheckout = {
      getCart: () => of([{ id: '1', name: 'Product', price: 10 }])
    }
    
    const service = new CartService(mockEventBus, mockStorage)
    service.syncWithCheckout(mockCheckout)
    
    expect(service.getItems()).toHaveLength(1)
  })
})

Mock Remote Module Loading

typescript.js
// host-app/src/app/app-routing.module.spec.ts
describe('App Routing', () => {
  it('should handle remote load failure', async () => {
    // Mock failed remote load
    spyOn(window, 'import').and.returnValue(
      Promise.reject(new Error('Remote unavailable'))
    )
    
    const module = await loadRemoteWithTimeout(
      () => import('products/ProductsModule'),
      1000,
      1
    ).catch(err => {
      return import('./fallback/products-fallback.module')
        .then(m => m.ProductsFallbackModule)
    })
    
    expect(module).toBeDefined()
  })
})

Strategy 3: Integration vs E2E Boundaries

Integration Tests: Test App Pairs

typescript.js
// Integration test: Products + Host
describe('Products Integration', () => {
  it('should load Products module in Host', async () => {
    const module = await import('products/ProductsModule')
      .then(m => m.ProductsModule)
    
    expect(module).toBeDefined()
  })
  
  it('should handle Products events in Host', () => {
    const hostEventBus = new EventBus()
    const productsService = new CartService(hostEventBus, mockStorage)
    
    productsService.addItem({ id: '1', name: 'Product', price: 10 })
    
    // Host should receive event
    hostEventBus.on('cart:item-added').subscribe(event => {
      expect(event.data.itemId).toBe('1')
    })
  })
})

E2E Tests: Test Critical Paths Only

typescript.js
// E2E test: Full user flow (sparingly)
describe('E2E: Add to Cart and Checkout', () => {
  it('should complete full checkout flow', async () => {
    // Start all apps
    await startHost()
    await startProducts()
    await startCheckout()
    
    // Test critical path only
    await page.goto('http://localhost:4200/products')
    await page.click('[data-testid="add-to-cart"]')
    await page.goto('http://localhost:4200/checkout')
    await expect(page.locator('[data-testid="cart-item"]')).toBeVisible()
  })
})

Key: E2E tests are expensive. Use sparingly for critical paths only.


Strategy 4: CI-Safe Testing

The Problem

Tests fail in CI because remotes aren't available.

The Solution: Mock Everything in CI

typescript.js
// jest.config.js
module.exports = {
  setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
  moduleNameMapper: {
    '^products/(.*)$': '<rootDir>/src/mocks/products/$1',
    '^checkout/(.*)$': '<rootDir>/src/mocks/checkout/$1'
  }
}
typescript.js
// src/mocks/products/ProductsModule.ts
export class ProductsModule {}
export class ProductsComponent {
  template = '<div>Mock Products</div>'
}

Test Isolation

typescript.js
// Each app tests independently
describe('Products App', () => {
  // No dependency on Checkout
  // No dependency on Host
  // Tests Products in isolation
})

Testing Failure Scenarios

Test Remote Unavailable

typescript.js
describe('Remote Loading', () => {
  it('should handle remote unavailable', async () => {
    spyOn(window, 'fetch').and.returnValue(
      Promise.reject(new Error('Network error'))
    )
    
    const module = await loadRemote('products/ProductsModule')
      .catch(() => import('./fallback/products-fallback.module'))
    
    expect(module).toBeDefined()
  })
})

Test Timeout

typescript.js
describe('Remote Loading', () => {
  it('should handle timeout', async () => {
    spyOn(window, 'fetch').and.returnValue(
      new Promise(resolve => setTimeout(resolve, 20000)) // Never resolves
    )
    
    const module = await loadRemoteWithTimeout(
      () => import('products/ProductsModule'),
      1000, // 1 second timeout
      1     // 1 retry
    ).catch(() => import('./fallback/products-fallback.module'))
    
    expect(module).toBeDefined()
  })
})

Test Pyramid for Micro Frontends

Unit Tests (70%)

  • Test services in isolation
  • Test components in isolation
  • Mock dependencies
  • Fast, reliable

Integration Tests (20%)

  • Test app pairs (Products + Host)
  • Test contracts (events, state)
  • Mock one app, test the other
  • Medium speed, reliable

E2E Tests (10%)

  • Test critical paths only
  • Test full user flows
  • Slow, can be flaky
  • Use sparingly

Production Checklist

  • [ ] Contract tests for all events
  • [ ] Unit tests for each app independently
  • [ ] Integration tests for app pairs
  • [ ] E2E tests for critical paths only
  • [ ] Mock remotes in CI
  • [ ] Test failure scenarios
  • [ ] Test timeout scenarios
  • [ ] Fast test suite (< 5 minutes)

Key Takeaways

  1. Test contracts, not implementation - Contracts are what matter
  2. Test independently - Each app tests alone
  3. Mock remotes - Don't require all apps running
  4. Test failures - What happens when things break?
  5. Fast tests - CI should complete in minutes

Remember: In micro frontends, testing is about contracts and isolation, not integration.

The next module: How to secure this architecture.