Testing microservices requires different strategies than monolithic applications. This guide covers testing patterns from unit tests to production verification.
The Testing Pyramid for Microservices#
┌───────────────┐
│ E2E Tests │ Few, slow, expensive
├───────────────┤
│ Contract │
│ Tests │ Verify service contracts
├───────────────┤
│ Integration │
│ Tests │ Test with real dependencies
├───────────────┤
│ Component │
│ Tests │ Test service in isolation
├───────────────┤
│ Unit Tests │ Many, fast, cheap
└───────────────┘
Unit Testing#
Test business logic in isolation:
1// order.service.test.ts
2import { OrderService } from './order.service';
3import { mockOrderRepository, mockPaymentGateway } from '../test/mocks';
4
5describe('OrderService', () => {
6 let service: OrderService;
7
8 beforeEach(() => {
9 service = new OrderService(
10 mockOrderRepository,
11 mockPaymentGateway
12 );
13 });
14
15 describe('createOrder', () => {
16 it('should create order with valid items', async () => {
17 const items = [
18 { productId: '1', quantity: 2, price: 29.99 },
19 ];
20
21 const order = await service.createOrder('user-123', items);
22
23 expect(order.status).toBe('pending');
24 expect(order.total).toBe(59.98);
25 expect(mockOrderRepository.save).toHaveBeenCalledWith(
26 expect.objectContaining({ userId: 'user-123' })
27 );
28 });
29
30 it('should reject empty orders', async () => {
31 await expect(service.createOrder('user-123', []))
32 .rejects.toThrow('Order must have at least one item');
33 });
34
35 it('should apply discount codes', async () => {
36 const items = [{ productId: '1', quantity: 1, price: 100 }];
37
38 const order = await service.createOrder('user-123', items, {
39 discountCode: 'SAVE20',
40 });
41
42 expect(order.discount).toBe(20);
43 expect(order.total).toBe(80);
44 });
45 });
46});Component Testing#
Test a service with its direct dependencies:
1// order.component.test.ts
2import { Test, TestingModule } from '@nestjs/testing';
3import { OrderController } from './order.controller';
4import { OrderService } from './order.service';
5import { PrismaService } from '../prisma/prisma.service';
6import { TestDatabase } from '../test/test-database';
7
8describe('Order Component', () => {
9 let app: TestingModule;
10 let controller: OrderController;
11 let db: TestDatabase;
12
13 beforeAll(async () => {
14 db = await TestDatabase.create();
15
16 app = await Test.createTestingModule({
17 controllers: [OrderController],
18 providers: [
19 OrderService,
20 {
21 provide: PrismaService,
22 useValue: db.prisma,
23 },
24 // Mock external services
25 {
26 provide: 'PAYMENT_SERVICE',
27 useValue: {
28 charge: jest.fn().mockResolvedValue({ id: 'payment-123' }),
29 },
30 },
31 ],
32 }).compile();
33
34 controller = app.get<OrderController>(OrderController);
35 });
36
37 afterAll(async () => {
38 await db.cleanup();
39 });
40
41 beforeEach(async () => {
42 await db.reset();
43 });
44
45 it('should create and retrieve order', async () => {
46 // Create order
47 const createResult = await controller.create({
48 userId: 'user-1',
49 items: [{ productId: 'prod-1', quantity: 2 }],
50 });
51
52 expect(createResult.id).toBeDefined();
53
54 // Retrieve order
55 const order = await controller.findOne(createResult.id);
56
57 expect(order.userId).toBe('user-1');
58 expect(order.items).toHaveLength(1);
59 });
60});Contract Testing with Pact#
Consumer Side#
1// order-consumer.pact.test.ts
2import { PactV3, MatchersV3 } from '@pact-foundation/pact';
3import { ProductClient } from './product.client';
4
5const { like, eachLike } = MatchersV3;
6
7const provider = new PactV3({
8 consumer: 'OrderService',
9 provider: 'ProductService',
10 dir: './pacts',
11});
12
13describe('Product Service Contract', () => {
14 it('should get product details', async () => {
15 await provider
16 .given('product 123 exists')
17 .uponReceiving('a request for product 123')
18 .withRequest({
19 method: 'GET',
20 path: '/api/products/123',
21 })
22 .willRespondWith({
23 status: 200,
24 headers: { 'Content-Type': 'application/json' },
25 body: {
26 id: like('123'),
27 name: like('Test Product'),
28 price: like(29.99),
29 inventory: like(100),
30 },
31 })
32 .executeTest(async (mockServer) => {
33 const client = new ProductClient(mockServer.url);
34 const product = await client.getProduct('123');
35
36 expect(product.name).toBe('Test Product');
37 expect(product.price).toBe(29.99);
38 });
39 });
40
41 it('should return 404 for non-existent product', async () => {
42 await provider
43 .given('product 999 does not exist')
44 .uponReceiving('a request for non-existent product')
45 .withRequest({
46 method: 'GET',
47 path: '/api/products/999',
48 })
49 .willRespondWith({
50 status: 404,
51 body: { error: like('Product not found') },
52 })
53 .executeTest(async (mockServer) => {
54 const client = new ProductClient(mockServer.url);
55
56 await expect(client.getProduct('999'))
57 .rejects.toThrow('Product not found');
58 });
59 });
60});Provider Side#
1// product-provider.pact.test.ts
2import { Verifier } from '@pact-foundation/pact';
3import { app } from '../src/app';
4
5describe('Product Provider Verification', () => {
6 let server: any;
7
8 beforeAll(async () => {
9 server = app.listen(3001);
10 });
11
12 afterAll(() => {
13 server.close();
14 });
15
16 it('should validate the expectations of OrderService', async () => {
17 const verifier = new Verifier({
18 providerBaseUrl: 'http://localhost:3001',
19 pactUrls: ['./pacts/OrderService-ProductService.json'],
20 stateHandlers: {
21 'product 123 exists': async () => {
22 await db.products.create({
23 id: '123',
24 name: 'Test Product',
25 price: 29.99,
26 inventory: 100,
27 });
28 },
29 'product 999 does not exist': async () => {
30 await db.products.deleteMany({ where: { id: '999' } });
31 },
32 },
33 });
34
35 await verifier.verifyProvider();
36 });
37});Integration Testing#
Test service interactions with real dependencies:
1// order-integration.test.ts
2import { Test } from '@nestjs/testing';
3import { INestApplication } from '@nestjs/common';
4import request from 'supertest';
5import { AppModule } from '../src/app.module';
6import { setupTestContainers } from './test-containers';
7
8describe('Order Integration Tests', () => {
9 let app: INestApplication;
10 let containers: Awaited<ReturnType<typeof setupTestContainers>>;
11
12 beforeAll(async () => {
13 // Start test containers
14 containers = await setupTestContainers();
15
16 const moduleRef = await Test.createTestingModule({
17 imports: [AppModule],
18 })
19 .overrideProvider('DATABASE_URL')
20 .useValue(containers.postgresUrl)
21 .overrideProvider('REDIS_URL')
22 .useValue(containers.redisUrl)
23 .compile();
24
25 app = moduleRef.createNestApplication();
26 await app.init();
27 });
28
29 afterAll(async () => {
30 await app.close();
31 await containers.stop();
32 });
33
34 describe('POST /orders', () => {
35 it('should create order and publish event', async () => {
36 const response = await request(app.getHttpServer())
37 .post('/orders')
38 .send({
39 userId: 'user-123',
40 items: [{ productId: 'prod-1', quantity: 2 }],
41 })
42 .expect(201);
43
44 expect(response.body).toMatchObject({
45 status: 'pending',
46 userId: 'user-123',
47 });
48
49 // Verify event was published
50 const events = await containers.getPublishedEvents('orders');
51 expect(events).toContainEqual(
52 expect.objectContaining({
53 type: 'OrderCreated',
54 orderId: response.body.id,
55 })
56 );
57 });
58 });
59});Using Testcontainers#
1// test-containers.ts
2import { PostgreSqlContainer } from '@testcontainers/postgresql';
3import { RedisContainer } from '@testcontainers/redis';
4import { KafkaContainer } from '@testcontainers/kafka';
5
6export async function setupTestContainers() {
7 const postgres = await new PostgreSqlContainer()
8 .withDatabase('test')
9 .start();
10
11 const redis = await new RedisContainer().start();
12
13 const kafka = await new KafkaContainer().start();
14
15 return {
16 postgresUrl: postgres.getConnectionUri(),
17 redisUrl: redis.getConnectionUrl(),
18 kafkaUrl: `${kafka.getHost()}:${kafka.getMappedPort(9093)}`,
19
20 async stop() {
21 await Promise.all([
22 postgres.stop(),
23 redis.stop(),
24 kafka.stop(),
25 ]);
26 },
27
28 async getPublishedEvents(topic: string) {
29 // Read from Kafka topic
30 },
31 };
32}End-to-End Testing#
1// e2e/checkout-flow.test.ts
2import { test, expect } from '@playwright/test';
3
4test.describe('Checkout Flow', () => {
5 test('should complete purchase', async ({ page }) => {
6 // Add item to cart
7 await page.goto('/products/123');
8 await page.click('button:has-text("Add to Cart")');
9
10 // Go to checkout
11 await page.click('a:has-text("Cart")');
12 await page.click('button:has-text("Checkout")');
13
14 // Fill shipping info
15 await page.fill('[name="address"]', '123 Main St');
16 await page.fill('[name="city"]', 'New York');
17 await page.fill('[name="zip"]', '10001');
18
19 // Submit order
20 await page.click('button:has-text("Place Order")');
21
22 // Verify confirmation
23 await expect(page.locator('h1')).toHaveText('Order Confirmed');
24 await expect(page.locator('.order-id')).toBeVisible();
25 });
26});Testing Strategies#
Chaos Testing#
1// chaos.test.ts
2describe('Resilience Tests', () => {
3 it('should handle database timeout gracefully', async () => {
4 // Inject latency
5 await toxiproxy.addToxic('postgres', {
6 type: 'latency',
7 attributes: { latency: 5000 },
8 });
9
10 const response = await request(app)
11 .get('/api/orders/123')
12 .timeout(3000);
13
14 expect(response.status).toBe(503);
15 expect(response.body.error).toBe('Service temporarily unavailable');
16 });
17
18 it('should use circuit breaker on repeated failures', async () => {
19 // Simulate service down
20 await mockServer.stubFor(
21 get('/products/123').willReturn(
22 aResponse().withStatus(500)
23 )
24 );
25
26 // First 5 requests fail
27 for (let i = 0; i < 5; i++) {
28 await request(app).post('/orders').send(orderData);
29 }
30
31 // Circuit should be open now - fast fail
32 const start = Date.now();
33 const response = await request(app).post('/orders').send(orderData);
34 const duration = Date.now() - start;
35
36 expect(duration).toBeLessThan(100); // Fast fail
37 expect(response.body.error).toBe('Service unavailable');
38 });
39});Best Practices#
- Test at the right level: Most tests should be unit/component tests
- Use contract tests: Catch breaking changes early
- Test failure modes: Services will fail, test handling
- Parallelize tests: Use containers for isolation
- Mock external services: Don't depend on third parties in CI
Conclusion#
Testing microservices requires a layered approach. Start with unit tests for business logic, use contract tests for service boundaries, and reserve E2E tests for critical user journeys.