Module 19: Testing Micro Frontends Like a Professional
Chapter 19 • Advanced
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:
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:
// 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:
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:
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
// 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
// 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
// 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
// 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
// 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
// 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
// jest.config.js
module.exports = {
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
moduleNameMapper: {
'^products/(.*)$': '<rootDir>/src/mocks/products/$1',
'^checkout/(.*)$': '<rootDir>/src/mocks/checkout/$1'
}
}
// src/mocks/products/ProductsModule.ts
export class ProductsModule {}
export class ProductsComponent {
template = '<div>Mock Products</div>'
}
Test Isolation
// 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
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
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
- Test contracts, not implementation - Contracts are what matter
- Test independently - Each app tests alone
- Mock remotes - Don't require all apps running
- Test failures - What happens when things break?
- 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.