Testing Strategies
Testing microservices is challenging due to distributed nature and service dependencies. Learn comprehensive testing strategies at every level.Learning Objectives:
- Implement unit testing with mocks
- Write integration tests for services
- Use contract testing with Pact
- Design end-to-end test strategies
- Set up test environments
Testing Pyramid for Microservices
Copy
┌─────────────────────────────────────────────────────────────────────────────┐
│ MICROSERVICES TESTING PYRAMID │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ▲ │
│ ╱ ╲ │
│ ╱ ╲ │
│ ╱ E2E ╲ Few, slow, expensive │
│ ╱───────╲ Test critical user journeys │
│ ╱ ╲ │
│ ╱ Component ╲ Service in isolation │
│ ╱─────────────╲ with real DB, mocked deps │
│ ╱ ╲ │
│ ╱ Contract ╲ Verify service compatibility │
│ ╱───────────────────╲ Consumer-driven contracts │
│ ╱ ╲ │
│ ╱ Integration ╲ Test with real dependencies │
│ ╱─────────────────────────╲ Database, message queue │
│ ╱ ╲ │
│ ╱ Unit ╲ Fast, isolated │
│ ╱───────────────────────────────╲ Many, quick, cheap │
│ │
│ │
│ ┌─────────────┬────────────┬────────────┬───────────────────────────────┐ │
│ │ Level │ Count │ Speed │ What to Test │ │
│ ├─────────────┼────────────┼────────────┼───────────────────────────────┤ │
│ │ Unit │ Thousands │ < 1ms │ Business logic, utilities │ │
│ │ Integration │ Hundreds │ < 1s │ DB queries, external calls │ │
│ │ Contract │ Tens │ < 5s │ API compatibility │ │
│ │ Component │ Tens │ < 30s │ Service endpoints │ │
│ │ E2E │ Few │ < 5min │ Critical paths │ │
│ └─────────────┴────────────┴────────────┴───────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Unit Testing
Testing Business Logic
Copy
// services/OrderService.js
class OrderService {
constructor(orderRepository, pricingService, inventoryClient) {
this.orderRepository = orderRepository;
this.pricingService = pricingService;
this.inventoryClient = inventoryClient;
}
async createOrder(customerId, items) {
// Validate items
if (!items || items.length === 0) {
throw new ValidationError('Order must have at least one item');
}
if (items.length > 50) {
throw new ValidationError('Order cannot have more than 50 items');
}
// Check inventory
const availability = await this.inventoryClient.checkAvailability(items);
const unavailable = availability.filter(item => !item.available);
if (unavailable.length > 0) {
throw new InsufficientInventoryError(unavailable);
}
// Calculate pricing
const pricing = await this.pricingService.calculate(items, customerId);
// Create order
const order = {
id: generateId(),
customerId,
items,
subtotal: pricing.subtotal,
tax: pricing.tax,
total: pricing.total,
status: 'PENDING',
createdAt: new Date()
};
await this.orderRepository.save(order);
return order;
}
calculateDiscount(order, discountCode) {
const discounts = {
'SAVE10': { type: 'percentage', value: 10 },
'FLAT50': { type: 'fixed', value: 50 },
'FREESHIP': { type: 'shipping', value: 0 }
};
const discount = discounts[discountCode];
if (!discount) {
throw new InvalidDiscountError(discountCode);
}
switch (discount.type) {
case 'percentage':
return order.subtotal * (discount.value / 100);
case 'fixed':
return Math.min(discount.value, order.subtotal);
case 'shipping':
return order.shippingCost;
default:
return 0;
}
}
}
Unit Tests with Jest
Copy
// tests/unit/OrderService.test.js
const { OrderService } = require('../../services/OrderService');
const { ValidationError, InsufficientInventoryError } = require('../../errors');
describe('OrderService', () => {
let orderService;
let mockOrderRepository;
let mockPricingService;
let mockInventoryClient;
beforeEach(() => {
// Create mocks
mockOrderRepository = {
save: jest.fn().mockResolvedValue(undefined),
findById: jest.fn()
};
mockPricingService = {
calculate: jest.fn().mockResolvedValue({
subtotal: 100,
tax: 10,
total: 110
})
};
mockInventoryClient = {
checkAvailability: jest.fn().mockResolvedValue([
{ productId: 'p1', available: true },
{ productId: 'p2', available: true }
])
};
orderService = new OrderService(
mockOrderRepository,
mockPricingService,
mockInventoryClient
);
});
describe('createOrder', () => {
const customerId = 'customer-123';
const validItems = [
{ productId: 'p1', quantity: 2 },
{ productId: 'p2', quantity: 1 }
];
it('should create order with valid items', async () => {
const order = await orderService.createOrder(customerId, validItems);
expect(order).toMatchObject({
customerId,
items: validItems,
subtotal: 100,
tax: 10,
total: 110,
status: 'PENDING'
});
expect(order.id).toBeDefined();
expect(mockOrderRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ customerId })
);
});
it('should throw ValidationError for empty items', async () => {
await expect(
orderService.createOrder(customerId, [])
).rejects.toThrow(ValidationError);
await expect(
orderService.createOrder(customerId, [])
).rejects.toThrow('Order must have at least one item');
});
it('should throw ValidationError for too many items', async () => {
const tooManyItems = Array(51).fill({ productId: 'p1', quantity: 1 });
await expect(
orderService.createOrder(customerId, tooManyItems)
).rejects.toThrow(ValidationError);
});
it('should throw InsufficientInventoryError when items unavailable', async () => {
mockInventoryClient.checkAvailability.mockResolvedValue([
{ productId: 'p1', available: true },
{ productId: 'p2', available: false }
]);
await expect(
orderService.createOrder(customerId, validItems)
).rejects.toThrow(InsufficientInventoryError);
});
it('should check inventory before calculating pricing', async () => {
await orderService.createOrder(customerId, validItems);
expect(mockInventoryClient.checkAvailability).toHaveBeenCalledBefore(
mockPricingService.calculate
);
});
});
describe('calculateDiscount', () => {
const order = { subtotal: 200, shippingCost: 15 };
it('should apply percentage discount correctly', () => {
const discount = orderService.calculateDiscount(order, 'SAVE10');
expect(discount).toBe(20); // 10% of 200
});
it('should apply fixed discount correctly', () => {
const discount = orderService.calculateDiscount(order, 'FLAT50');
expect(discount).toBe(50);
});
it('should not exceed subtotal for fixed discount', () => {
const smallOrder = { subtotal: 30 };
const discount = orderService.calculateDiscount(smallOrder, 'FLAT50');
expect(discount).toBe(30);
});
it('should apply shipping discount correctly', () => {
const discount = orderService.calculateDiscount(order, 'FREESHIP');
expect(discount).toBe(15);
});
it('should throw for invalid discount code', () => {
expect(() => {
orderService.calculateDiscount(order, 'INVALID');
}).toThrow('Invalid discount code');
});
});
});
Integration Testing
Database Integration Tests
Copy
// tests/integration/OrderRepository.test.js
const { OrderRepository } = require('../../repositories/OrderRepository');
const { createTestDatabase, closeTestDatabase } = require('../helpers/database');
describe('OrderRepository Integration', () => {
let orderRepository;
let db;
beforeAll(async () => {
db = await createTestDatabase();
orderRepository = new OrderRepository(db);
});
afterAll(async () => {
await closeTestDatabase(db);
});
beforeEach(async () => {
await db.query('DELETE FROM orders');
await db.query('DELETE FROM order_items');
});
describe('save', () => {
it('should persist order to database', async () => {
const order = {
id: 'order-123',
customerId: 'customer-456',
items: [{ productId: 'p1', quantity: 2, price: 50 }],
total: 100,
status: 'PENDING'
};
await orderRepository.save(order);
const result = await db.query(
'SELECT * FROM orders WHERE id = $1',
[order.id]
);
expect(result.rows[0]).toMatchObject({
id: order.id,
customer_id: order.customerId,
total: '100.00',
status: 'PENDING'
});
});
it('should save order items', async () => {
const order = {
id: 'order-123',
customerId: 'customer-456',
items: [
{ productId: 'p1', quantity: 2, price: 50 },
{ productId: 'p2', quantity: 1, price: 30 }
],
total: 130,
status: 'PENDING'
};
await orderRepository.save(order);
const result = await db.query(
'SELECT * FROM order_items WHERE order_id = $1',
[order.id]
);
expect(result.rows).toHaveLength(2);
});
});
describe('findByCustomerId', () => {
beforeEach(async () => {
// Seed test data
await orderRepository.save({
id: 'order-1',
customerId: 'customer-A',
items: [],
total: 100,
status: 'COMPLETED'
});
await orderRepository.save({
id: 'order-2',
customerId: 'customer-A',
items: [],
total: 200,
status: 'PENDING'
});
await orderRepository.save({
id: 'order-3',
customerId: 'customer-B',
items: [],
total: 150,
status: 'COMPLETED'
});
});
it('should return orders for specific customer', async () => {
const orders = await orderRepository.findByCustomerId('customer-A');
expect(orders).toHaveLength(2);
expect(orders.every(o => o.customerId === 'customer-A')).toBe(true);
});
it('should return empty array for customer with no orders', async () => {
const orders = await orderRepository.findByCustomerId('customer-C');
expect(orders).toEqual([]);
});
});
});
API Integration Tests
Copy
// tests/integration/OrderAPI.test.js
const request = require('supertest');
const { createApp } = require('../../app');
const { createTestDatabase, closeTestDatabase } = require('../helpers/database');
describe('Order API Integration', () => {
let app;
let db;
let authToken;
beforeAll(async () => {
db = await createTestDatabase();
app = createApp({ db });
// Get auth token
authToken = await getTestAuthToken();
});
afterAll(async () => {
await closeTestDatabase(db);
});
beforeEach(async () => {
await db.query('DELETE FROM orders');
});
describe('POST /orders', () => {
it('should create order and return 201', async () => {
const orderData = {
items: [
{ productId: 'product-1', quantity: 2 }
]
};
const response = await request(app)
.post('/orders')
.set('Authorization', `Bearer ${authToken}`)
.send(orderData)
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(String),
status: 'PENDING',
items: expect.arrayContaining([
expect.objectContaining({ productId: 'product-1' })
])
});
});
it('should return 400 for invalid order data', async () => {
const response = await request(app)
.post('/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({ items: [] })
.expect(400);
expect(response.body.error).toContain('at least one item');
});
it('should return 401 without auth token', async () => {
await request(app)
.post('/orders')
.send({ items: [{ productId: 'p1', quantity: 1 }] })
.expect(401);
});
});
describe('GET /orders/:id', () => {
it('should return order by id', async () => {
// Create order first
const createResponse = await request(app)
.post('/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({ items: [{ productId: 'p1', quantity: 1 }] });
const orderId = createResponse.body.id;
const response = await request(app)
.get(`/orders/${orderId}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.id).toBe(orderId);
});
it('should return 404 for non-existent order', async () => {
await request(app)
.get('/orders/non-existent-id')
.set('Authorization', `Bearer ${authToken}`)
.expect(404);
});
});
});
Contract Testing with Pact
Consumer Contract Test
Copy
// tests/contract/OrderService.consumer.test.js
const { Pact } = require('@pact-foundation/pact');
const { PaymentClient } = require('../../clients/PaymentClient');
const path = require('path');
describe('Order Service - Payment Service Contract', () => {
const provider = new Pact({
consumer: 'OrderService',
provider: 'PaymentService',
port: 8081,
log: path.resolve(process.cwd(), 'logs', 'pact.log'),
dir: path.resolve(process.cwd(), 'pacts'),
logLevel: 'warn'
});
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
afterEach(() => provider.verify());
describe('processPayment', () => {
it('should process payment successfully', async () => {
// Define expected interaction
await provider.addInteraction({
state: 'customer has valid payment method',
uponReceiving: 'a request to process payment',
withRequest: {
method: 'POST',
path: '/payments',
headers: {
'Content-Type': 'application/json'
},
body: {
orderId: '12345',
amount: 100.00,
currency: 'USD',
customerId: 'customer-123'
}
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json'
},
body: {
id: like('pay_abc123'),
orderId: '12345',
status: 'COMPLETED',
amount: 100.00,
processedAt: like('2024-01-15T10:30:00Z')
}
}
});
// Execute the actual client
const client = new PaymentClient(`http://localhost:8081`);
const result = await client.processPayment({
orderId: '12345',
amount: 100.00,
currency: 'USD',
customerId: 'customer-123'
});
expect(result.status).toBe('COMPLETED');
expect(result.orderId).toBe('12345');
});
it('should handle payment failure', async () => {
await provider.addInteraction({
state: 'customer has insufficient funds',
uponReceiving: 'a request to process payment',
withRequest: {
method: 'POST',
path: '/payments',
headers: {
'Content-Type': 'application/json'
},
body: {
orderId: '12345',
amount: 10000.00,
currency: 'USD',
customerId: 'customer-456'
}
},
willRespondWith: {
status: 402,
headers: {
'Content-Type': 'application/json'
},
body: {
error: 'INSUFFICIENT_FUNDS',
message: like('Payment declined due to insufficient funds')
}
}
});
const client = new PaymentClient(`http://localhost:8081`);
await expect(
client.processPayment({
orderId: '12345',
amount: 10000.00,
currency: 'USD',
customerId: 'customer-456'
})
).rejects.toThrow('INSUFFICIENT_FUNDS');
});
});
});
// Pact matchers
const { like, eachLike, term } = require('@pact-foundation/pact').Matchers;
Provider Contract Test
Copy
// tests/contract/PaymentService.provider.test.js
const { Verifier } = require('@pact-foundation/pact');
const { createApp } = require('../../app');
const path = require('path');
describe('Payment Service - Provider Verification', () => {
let server;
beforeAll(async () => {
const app = createApp();
server = app.listen(8082);
});
afterAll(() => {
server.close();
});
it('should validate the expectations of Order Service', async () => {
const opts = {
provider: 'PaymentService',
providerBaseUrl: 'http://localhost:8082',
pactUrls: [
path.resolve(process.cwd(), 'pacts', 'orderservice-paymentservice.json')
],
// Or use Pact Broker
// pactBrokerUrl: 'https://your-broker.pactflow.io',
// pactBrokerToken: process.env.PACT_BROKER_TOKEN,
stateHandlers: {
'customer has valid payment method': async () => {
// Set up test data for valid payment method
await seedTestData({
customerId: 'customer-123',
paymentMethods: [{ type: 'card', valid: true }]
});
},
'customer has insufficient funds': async () => {
// Set up test data for insufficient funds
await seedTestData({
customerId: 'customer-456',
balance: 0
});
}
},
publishVerificationResult: process.env.CI === 'true',
providerVersion: process.env.GIT_COMMIT || '1.0.0'
};
await new Verifier(opts).verifyProvider();
});
});
Component Testing
Copy
// tests/component/OrderService.component.test.js
const { GenericContainer, Wait } = require('testcontainers');
const request = require('supertest');
const { createApp } = require('../../app');
describe('Order Service Component Test', () => {
let postgresContainer;
let kafkaContainer;
let app;
beforeAll(async () => {
// Start PostgreSQL container
postgresContainer = await new GenericContainer('postgres:15-alpine')
.withEnvironment({
POSTGRES_DB: 'orders_test',
POSTGRES_USER: 'test',
POSTGRES_PASSWORD: 'test'
})
.withExposedPorts(5432)
.withWaitStrategy(Wait.forLogMessage('database system is ready'))
.start();
// Start Kafka container (optional)
kafkaContainer = await new GenericContainer('confluentinc/cp-kafka:7.5.0')
.withExposedPorts(9092)
.start();
// Create app with real containers
const dbUrl = `postgresql://test:test@${postgresContainer.getHost()}:${postgresContainer.getMappedPort(5432)}/orders_test`;
app = createApp({
databaseUrl: dbUrl,
kafkaBrokers: `${kafkaContainer.getHost()}:${kafkaContainer.getMappedPort(9092)}`
});
// Run migrations
await runMigrations(dbUrl);
}, 60000);
afterAll(async () => {
await postgresContainer?.stop();
await kafkaContainer?.stop();
});
describe('Order Lifecycle', () => {
it('should complete full order flow', async () => {
// 1. Create order
const createResponse = await request(app)
.post('/orders')
.set('Authorization', 'Bearer test-token')
.send({
customerId: 'customer-123',
items: [
{ productId: 'product-1', quantity: 2 }
]
})
.expect(201);
const orderId = createResponse.body.id;
expect(createResponse.body.status).toBe('PENDING');
// 2. Confirm order
await request(app)
.post(`/orders/${orderId}/confirm`)
.set('Authorization', 'Bearer test-token')
.expect(200);
// 3. Check order status
const statusResponse = await request(app)
.get(`/orders/${orderId}`)
.set('Authorization', 'Bearer test-token')
.expect(200);
expect(statusResponse.body.status).toBe('CONFIRMED');
});
});
});
End-to-End Testing
Copy
// tests/e2e/checkout.e2e.test.js
const axios = require('axios');
describe('Checkout E2E Flow', () => {
const baseUrl = process.env.E2E_BASE_URL || 'http://localhost:8080';
let authToken;
let userId;
beforeAll(async () => {
// Login and get auth token
const loginResponse = await axios.post(`${baseUrl}/auth/login`, {
email: '[email protected]',
password: 'test-password'
});
authToken = loginResponse.data.accessToken;
userId = loginResponse.data.userId;
});
const api = () => axios.create({
baseURL: baseUrl,
headers: { Authorization: `Bearer ${authToken}` }
});
describe('Complete Checkout Journey', () => {
let orderId;
it('should add items to cart', async () => {
const response = await api().post('/cart/items', {
productId: 'product-123',
quantity: 2
});
expect(response.status).toBe(200);
expect(response.data.items).toHaveLength(1);
});
it('should create order from cart', async () => {
const response = await api().post('/orders', {
shippingAddress: {
street: '123 Test St',
city: 'Test City',
country: 'US',
postalCode: '12345'
}
});
expect(response.status).toBe(201);
orderId = response.data.id;
expect(response.data.status).toBe('PENDING');
});
it('should process payment', async () => {
const response = await api().post(`/orders/${orderId}/pay`, {
paymentMethod: {
type: 'card',
token: 'tok_visa'
}
});
expect(response.status).toBe(200);
expect(response.data.paymentStatus).toBe('COMPLETED');
});
it('should show order as confirmed', async () => {
// Wait for async processing
await new Promise(resolve => setTimeout(resolve, 2000));
const response = await api().get(`/orders/${orderId}`);
expect(response.status).toBe(200);
expect(response.data.status).toBe('CONFIRMED');
expect(response.data.paymentId).toBeDefined();
});
it('should update inventory', async () => {
const response = await api().get('/inventory/product-123');
expect(response.status).toBe(200);
// Verify inventory was reduced
});
});
});
Test Environment Setup
Copy
# docker-compose.test.yml
version: '3.8'
services:
test-db:
image: postgres:15-alpine
environment:
POSTGRES_DB: test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- "5433:5432"
tmpfs:
- /var/lib/postgresql/data # Speed up tests
test-redis:
image: redis:7-alpine
ports:
- "6380:6379"
test-kafka:
image: confluentinc/cp-kafka:7.5.0
environment:
KAFKA_NODE_ID: 1
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9093
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@test-kafka:9093
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
CLUSTER_ID: 'test-cluster-id'
ports:
- "9093:9092"
Jest Configuration
Copy
// jest.config.js
module.exports = {
projects: [
{
displayName: 'unit',
testMatch: ['<rootDir>/tests/unit/**/*.test.js'],
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/tests/setup/unit.js']
},
{
displayName: 'integration',
testMatch: ['<rootDir>/tests/integration/**/*.test.js'],
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/tests/setup/integration.js'],
globalSetup: '<rootDir>/tests/setup/integration-global-setup.js',
globalTeardown: '<rootDir>/tests/setup/integration-global-teardown.js'
},
{
displayName: 'e2e',
testMatch: ['<rootDir>/tests/e2e/**/*.test.js'],
testEnvironment: 'node',
testTimeout: 30000
}
],
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
Interview Questions
Q1: How do you test microservices interactions?
Q1: How do you test microservices interactions?
Answer:Approaches:
- Contract Testing (Pact)
- Consumer defines expected interactions
- Provider verifies it can meet expectations
- Catches breaking changes early
- Integration Testing
- Test with real dependencies (test containers)
- Slower but more realistic
- Good for database interactions
- Service Virtualization
- Mock external services
- Consistent test data
- Faster than real services
- Contract tests for API compatibility
- Integration tests for data integrity
- Mocks for unit tests
Q2: What is contract testing and why is it important?
Q2: What is contract testing and why is it important?
Answer:Contract Testing:
- Consumer defines expectations (contract)
- Provider verifies it meets contract
- Changes detected before deployment
- Prevents integration failures
- Enables independent deployment
- Faster feedback than E2E tests
- Documents service interactions
- Consumer writes contract tests
- Generates contract file
- Provider runs verification
- Breaks build if contract broken
Q3: How do you handle test data in microservices?
Q3: How do you handle test data in microservices?
Answer:Strategies:
-
Test Containers
- Spin up real databases
- Isolated per test suite
- Clean state each run
-
Test Data Builders
Copy
OrderBuilder.create() .withCustomer('123') .withItems([...]) .build(); -
Database Seeding
- Known state before tests
- Fixtures or factories
-
Data Cleanup
- Truncate after tests
- Transaction rollback
- Isolated test databases
- Each test manages own data
- No dependencies between tests
- Use factories for complex objects
Summary
Key Takeaways
- Follow the testing pyramid
- Unit tests for business logic
- Contract tests for API compatibility
- Integration tests with test containers
- E2E for critical paths only
Next Steps
In the next chapter, we’ll cover Interview Preparation - common questions and system design exercises.