Testing Microservices
Test microservices with contract testing, service virtualization, Docker Compose integration, and chaos testing strategies
The Microservices Testing Challenge
Microservices multiply the testing complexity. Each service works in isolation, but bugs often hide in the interactions between services. You need a layered strategy: unit tests within services, contract tests between services, integration tests for critical paths, and chaos tests for resilience.
Testing Layers for Microservices:
Unit tests (fast, isolated) -> Service-level tests (API boundary) -> Contract tests (inter-service) -> Integration tests (real dependencies) -> Chaos tests (resilience)
Service-Level Testing
// Test a single microservice in isolation
// order-service/tests/orders.test.ts
import request from 'supertest';
import app from '../app';
import { server } from '../mocks/server'; // MSW for external services
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('Order Service', () => {
test('creates an order', async () => {
// MSW mocks the Product Service response
const res = await request(app)
.post('/api/orders')
.send({
userId: 'user-1',
items: [{ productId: 'prod-1', quantity: 2 }],
})
.expect(201);
expect(res.body.order).toMatchObject({
userId: 'user-1',
status: 'pending',
total: expect.any(Number),
});
});
test('handles product service failure', async () => {
// Override MSW to simulate product service down
server.use(
http.get('http://product-service/api/products/:id', () => {
return HttpResponse.json(null, { status: 503 });
})
);
const res = await request(app)
.post('/api/orders')
.send({
userId: 'user-1',
items: [{ productId: 'prod-1', quantity: 2 }],
});
expect(res.status).toBe(503);
expect(res.body.error).toContain('Product service unavailable');
});
});
Contract Testing Between Services
// Consumer side: Order Service consumes Product Service
// order-service/tests/product-contract.test.ts
import { PactV3 } from '@pact-foundation/pact';
const provider = new PactV3({
consumer: 'OrderService',
provider: 'ProductService',
});
describe('Product Service Contract', () => {
test('get product by ID', async () => {
provider
.given('product with ID prod-1 exists')
.uponReceiving('a request for product prod-1')
.withRequest({
method: 'GET',
path: '/api/products/prod-1',
})
.willRespondWith({
status: 200,
body: {
id: 'prod-1',
name: 'Widget',
price: 29.99,
inStock: true,
},
});
await provider.executeTest(async (mockServer) => {
const product = await fetchProduct('prod-1', mockServer.url);
expect(product.price).toBe(29.99);
expect(product.inStock).toBe(true);
});
// Pact file is generated and shared with provider
});
});
// Provider side: Product Service verifies the contract
// product-service/tests/verify-contracts.test.ts
import { Verifier } from '@pact-foundation/pact';
test('verifies contracts with consumers', async () => {
const verifier = new Verifier({
providerBaseUrl: 'http://localhost:4000',
pactUrls: ['./pacts/OrderService-ProductService.json'],
stateHandlers: {
'product with ID prod-1 exists': async () => {
await db.products.create({
id: 'prod-1', name: 'Widget', price: 29.99, inStock: true,
});
},
},
});
await verifier.verifyProvider();
});
Integration Testing with Docker Compose
# docker-compose.test.yml
version: '3.8'
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7
ports:
- "6380:6379"
order-service:
build: ./order-service
environment:
DATABASE_URL: postgresql://test:test@postgres:5432/testdb
REDIS_URL: redis://redis:6379
PRODUCT_SERVICE_URL: http://product-service:4000
depends_on:
postgres:
condition: service_healthy
ports:
- "3001:3000"
product-service:
build: ./product-service
environment:
DATABASE_URL: postgresql://test:test@postgres:5432/testdb
depends_on:
postgres:
condition: service_healthy
ports:
- "4001:4000"
# Run integration tests
# docker compose -f docker-compose.test.yml up -d
# npm run test:integration
# docker compose -f docker-compose.test.yml down -v
Testing Event-Driven Systems & Chaos Testing
// Testing event-driven microservices
describe('Order Events', () => {
test('publishes OrderCreated event', async () => {
const events: any[] = [];
messageBroker.subscribe('order.created', (event) => {
events.push(event);
});
await request(app)
.post('/api/orders')
.send({ userId: 'user-1', items: [{ productId: 'p1', quantity: 1 }] })
.expect(201);
// Wait for async event
await waitFor(() => expect(events).toHaveLength(1));
expect(events[0]).toMatchObject({
type: 'order.created',
payload: { userId: 'user-1' },
});
});
test('handles duplicate events idempotently', async () => {
const event = {
id: 'evt-1',
type: 'payment.completed',
payload: { orderId: 'order-1' },
};
// Process same event twice
await eventHandler.handle(event);
await eventHandler.handle(event);
// Order should only be updated once
const order = await db.orders.findById('order-1');
expect(order.status).toBe('paid');
});
});
// Chaos testing basics
// Simulate random failures to test resilience
describe('Resilience', () => {
test('circuit breaker opens after failures', async () => {
// Simulate 5 consecutive failures
for (let i = 0; i < 5; i++) {
server.use(
http.get('http://product-service/api/products/:id', () => {
return HttpResponse.json(null, { status: 500 });
})
);
await request(app).get('/api/orders/with-products').expect(503);
}
// Circuit should be open - returns cached/fallback data
const res = await request(app).get('/api/orders/with-products');
expect(res.status).toBe(200);
expect(res.body.source).toBe('cache');
});
});
Key Takeaways
- Test each service in isolation with mocked dependencies first
- Use contract tests (Pact) to verify inter-service API compatibility
- Docker Compose enables realistic multi-service integration tests
- Test event-driven flows for idempotency and ordering guarantees
- Add chaos testing to verify circuit breakers, retries, and fallbacks