TechLead

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

Continue Learning