Your API doesn't exist in isolation. It talks to databases, third-party services, and other microservices. Unit tests can't catch integration bugs. You need integration testing to verify services work together correctly. But spinning up all dependencies for every test is slow and brittle.
Enter contract testing. Instead of testing against real services, you test against contracts that define expected behavior. This guide covers integration testing strategies, consumer-driven contract testing with Pact, API mocking, test data management, and how to test microservices effectively without the pain of end-to-end testing.
Integration Testing vs Contract Testing
Traditional Integration Testing
Test your service by calling real external services or databases. Gives high confidence but is slow and fragile.
✓ Advantages:
- • Tests real interactions
- • High confidence
- • Catches real-world issues
- • No mocking needed
✗ Disadvantages:
- • Slow (network calls, DB queries)
- • Brittle (external service down = tests fail)
- • Expensive (need real services running)
- • Hard to test edge cases
- • Test data management complex
Contract Testing (Consumer-Driven)
Consumer defines what it expects from provider. Provider verifies it meets the contract. Fast, reliable, and focused.
✓ Advantages:
- • Fast (no real services needed)
- • Reliable (no external dependencies)
- • Easy to test edge cases
- • Clear API contracts
- • Prevents breaking changes
⚠Limitations:
- • Doesn't test real integration
- • Requires discipline to maintain
- • Learning curve for Pact
- • Won't catch networking issues
✓ Best Approach: Use Both!
Contract tests in CI pipeline (fast feedback), integration tests in staging (pre-production validation).
- • Contract tests: Every commit, fast feedback (seconds)
- • Integration tests: Before deployment, thorough validation (minutes)
- • E2E tests: Post-deployment smoke tests (catch environment issues)
Consumer-Driven Contract Testing with Pact
How Pact Works
Consumer writes a test defining what it expects from provider. Pact generates a contract file. Provider verifies it can fulfill the contract.
The Flow:
- 1. Consumer Test: Consumer defines expected request/response
- 2. Contract Generated: Pact creates JSON contract file
- 3. Contract Published: Contract uploaded to Pact Broker
- 4. Provider Verification: Provider runs real API against contract
- 5. Results Published: Pass/fail results uploaded to broker
- 6. Can Deploy?: Check if contracts verified before deploying
Consumer Side: Writing a Pact Test
Consumer test defines what it needs from the provider API.
// Consumer: Frontend app that calls User Service
// pact-test.spec.js
import { Pact } from '@pact-foundation/pact';
import { getUser } from './userService';
const provider = new Pact({
consumer: 'FrontendApp',
provider: 'UserService',
port: 1234,
});
describe('User Service Pact', () => {
before(() => provider.setup());
after(() => provider.finalize());
describe('GET /users/:id', () => {
before(() => {
// Define expected interaction
return provider.addInteraction({
state: 'user with ID 123 exists',
uponReceiving: 'a request for user 123',
withRequest: {
method: 'GET',
path: '/users/123',
headers: {
Accept: 'application/json',
},
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json',
},
body: {
id: '123',
name: 'John Doe',
email: '[email protected]',
},
},
});
});
it('should return user data', async () => {
// Call your actual service code
const user = await getUser('123');
expect(user.id).toBe('123');
expect(user.name).toBe('John Doe');
expect(user.email).toBe('[email protected]');
});
});
});
// After test runs, Pact generates:
// pacts/FrontendApp-UserService.jsonProvider Side: Verifying the Contract
Provider runs its real API against the consumer's contract to verify it works.
// Provider: User Service verifies contracts
// pact-verification.spec.js
const { Verifier } = require('@pact-foundation/pact');
const app = require('./app'); // Your Express/Fastify app
const server = app.listen(3000);
describe('Pact Verification', () => {
after(() => server.close());
it('should validate contracts', () => {
return new Verifier({
provider: 'UserService',
providerBaseUrl: 'http://localhost:3000',
// Fetch contracts from Pact Broker
pactBrokerUrl: 'https://pact-broker.example.com',
// Setup provider state before each interaction
stateHandlers: {
'user with ID 123 exists': () => {
// Create test user in database
return db.users.create({
id: '123',
name: 'John Doe',
email: '[email protected]'
});
},
'user with ID 999 does not exist': () => {
// Ensure user doesn't exist
return db.users.delete('999');
}
}
})
.verifyProvider()
.then(output => {
console.log('Pact verification successful');
});
});
});
// This runs your REAL API against expected requests
// Catches breaking changes before deployment!Provider States - Testing Different Scenarios
Use provider states to test different scenarios: resource exists, doesn't exist, user unauthorized, etc.
// Consumer defines multiple states
describe('User Service Pact', () => {
describe('when user exists', () => {
before(() => {
return provider.addInteraction({
state: 'user with ID 123 exists',
uponReceiving: 'request for existing user',
// ...
willRespondWith: {
status: 200,
body: { id: '123', name: 'John' }
}
});
});
// Test...
});
describe('when user does not exist', () => {
before(() => {
return provider.addInteraction({
state: 'user with ID 999 does not exist',
uponReceiving: 'request for non-existent user',
// ...
willRespondWith: {
status: 404,
body: { error: 'User not found' }
}
});
});
// Test...
});
describe('when unauthorized', () => {
before(() => {
return provider.addInteraction({
state: 'no auth token provided',
uponReceiving: 'request without auth',
withRequest: {
method: 'GET',
path: '/users/me',
// No Authorization header
},
willRespondWith: {
status: 401,
body: { error: 'Unauthorized' }
}
});
});
// Test...
});
});
// Provider must handle all these states!API Mocking & Stubbing
When to Mock External APIs
Mock third-party APIs to avoid hitting rate limits, costs, or unreliable services during testing.
Mock When:
- • Third-party API has rate limits (Stripe, Twilio, SendGrid)
- • API costs money per request (payment processing)
- • External service is unreliable
- • Testing edge cases (errors, timeouts, specific responses)
- • You don't control the service
- • Need fast, deterministic tests
✓ Mocking with Mock Service Worker (MSW)
MSW intercepts HTTP requests at the network level. Works in Node and browser.
// mocks/handlers.js
import { rest } from 'msw';
export const handlers = [
// Mock Stripe API
rest.post('https://api.stripe.com/v1/charges', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
id: 'ch_mock123',
amount: 1000,
status: 'succeeded'
})
);
}),
// Mock payment failure
rest.post('https://api.stripe.com/v1/charges', (req, res, ctx) => {
const { amount } = req.body;
if (amount > 100000) {
return res(
ctx.status(402),
ctx.json({
error: {
type: 'card_error',
code: 'insufficient_funds'
}
})
);
}
return res(ctx.json({ id: 'ch_123', status: 'succeeded' }));
}),
// Mock GitHub API
rest.get('https://api.github.com/users/:username', (req, res, ctx) => {
const { username } = req.params;
return res(
ctx.json({
login: username,
name: 'Mock User',
public_repos: 42
})
);
})
];
// Setup in tests
import { setupServer } from 'msw/node';
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());✓ Mocking with Nock (Node.js)
Nock intercepts HTTP requests in Node.js. Simple and powerful for backend testing.
import nock from 'nock';
describe('Payment Service', () => {
afterEach(() => nock.cleanAll());
it('should process payment successfully', async () => {
// Mock Stripe API response
nock('https://api.stripe.com')
.post('/v1/charges')
.reply(200, {
id: 'ch_123',
amount: 1000,
status: 'succeeded'
});
const result = await processPayment({
amount: 1000,
token: 'tok_visa'
});
expect(result.status).toBe('succeeded');
});
it('should handle payment failure', async () => {
// Mock error response
nock('https://api.stripe.com')
.post('/v1/charges')
.reply(402, {
error: {
type: 'card_error',
code: 'insufficient_funds',
message: 'Your card has insufficient funds.'
}
});
await expect(
processPayment({ amount: 1000, token: 'tok_visa' })
).rejects.toThrow('insufficient funds');
});
it('should handle network timeout', async () => {
// Mock timeout
nock('https://api.stripe.com')
.post('/v1/charges')
.delayConnection(5000)
.reply(200, { id: 'ch_123' });
// Test your timeout handling
await expect(
processPayment({ amount: 1000, token: 'tok_visa' })
).rejects.toThrow('Request timeout');
});
});✓ WireMock - Standalone Mock Server
Run a real HTTP mock server. Great for testing multiple services or language-agnostic testing.
// Start WireMock server
docker run -p 8080:8080 wiremock/wiremock
// Configure stub via HTTP API
POST http://localhost:8080/__admin/mappings
{
"request": {
"method": "GET",
"urlPath": "/api/users/123"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": {
"id": "123",
"name": "John Doe"
}
}
}
// Or use JSON files
// mappings/user-exists.json
{
"request": {
"method": "GET",
"urlPathPattern": "/api/users/.*"
},
"response": {
"status": 200,
"jsonBody": {
"id": "{{request.path.[2]}}",
"name": "Mock User"
},
"transformers": ["response-template"]
}
}
// Now your tests hit http://localhost:8080 instead of real APITest Data Management
✓ Database Seeding Strategy
Set up known test data before tests run. Clean up after to keep tests isolated.
// seed-test-data.js
export async function seedDatabase() {
// Create test users
await db.users.createMany([
{ id: '1', name: 'Alice', email: '[email protected]' },
{ id: '2', name: 'Bob', email: '[email protected]' },
{ id: '3', name: 'Charlie', email: '[email protected]' }
]);
// Create test posts
await db.posts.createMany([
{ id: '1', title: 'Post 1', authorId: '1' },
{ id: '2', title: 'Post 2', authorId: '1' },
{ id: '3', title: 'Post 3', authorId: '2' }
]);
}
export async function cleanDatabase() {
await db.posts.deleteMany();
await db.users.deleteMany();
}
// In tests
describe('User API Integration Tests', () => {
beforeAll(async () => {
await seedDatabase();
});
afterAll(async () => {
await cleanDatabase();
});
it('should get user with posts', async () => {
const response = await request(app)
.get('/api/users/1?include=posts');
expect(response.body.id).toBe('1');
expect(response.body.posts).toHaveLength(2);
});
});✓ Test Factories for Dynamic Data
Use factories to generate test data dynamically. Avoids hardcoded IDs and makes tests flexible.
// factories/user.factory.js
import { faker } from '@faker-js/faker';
export function createUserFactory(overrides = {}) {
return {
id: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
createdAt: faker.date.past(),
...overrides
};
}
export function createPostFactory(overrides = {}) {
return {
id: faker.string.uuid(),
title: faker.lorem.sentence(),
content: faker.lorem.paragraphs(3),
published: true,
authorId: faker.string.uuid(),
...overrides
};
}
// In tests
describe('Post API', () => {
it('should create post', async () => {
const user = await db.users.create(createUserFactory());
const postData = createPostFactory({
authorId: user.id,
title: 'My Test Post' // Override specific field
});
const response = await request(app)
.post('/api/posts')
.send(postData);
expect(response.status).toBe(201);
expect(response.body.title).toBe('My Test Post');
expect(response.body.authorId).toBe(user.id);
});
});✓ Database Transactions for Test Isolation
Wrap each test in a database transaction and rollback after. Keeps tests isolated without manual cleanup.
// Using Prisma example
describe('User API', () => {
let transaction;
beforeEach(async () => {
// Start transaction
transaction = await prisma.$transaction(async (tx) => {
// All operations in this test use tx
return tx;
});
});
afterEach(async () => {
// Rollback transaction (automatic cleanup!)
await transaction.$rollback();
});
it('should create user', async () => {
const user = await transaction.user.create({
data: { name: 'Test', email: '[email protected]' }
});
expect(user.id).toBeDefined();
// After test, transaction rolls back
// User is NOT in database for next test
});
});
// Alternative: Use testcontainers
// Spin up fresh database for each test suite
import { PostgreSqlContainer } from '@testcontainers/postgresql';
let container;
let db;
beforeAll(async () => {
container = await new PostgreSqlContainer().start();
db = await connectToDatabase(container.getConnectionUri());
});
afterAll(async () => {
await container.stop();
});Testing Microservices
The Testing Pyramid for Microservices
Balance different types of tests. More unit tests, fewer E2E tests.
E2E Tests (Fewest)
Test entire system end-to-end. Slow, brittle, expensive. Use sparingly for critical flows.
Integration Tests (Some)
Test service with real database, mock external services. Verify integrations work.
Contract Tests (More)
Test service boundaries. Fast, reliable. Catch breaking changes between services.
Unit Tests (Most)
Test individual functions. Fast, isolated. Bulk of your tests should be here.
✓ Service-to-Service Communication Testing
Test that services communicate correctly via HTTP, message queues, or gRPC.
// Scenario: Order Service calls Inventory Service
describe('Order Service Integration', () => {
it('should check inventory before creating order', async () => {
// Mock Inventory Service response
nock('http://inventory-service:3001')
.get('/api/products/ABC123/stock')
.reply(200, { productId: 'ABC123', inStock: 10 });
// Create order via Order Service
const response = await request(orderService)
.post('/api/orders')
.send({
productId: 'ABC123',
quantity: 2
});
expect(response.status).toBe(201);
expect(response.body.status).toBe('confirmed');
});
it('should reject order if out of stock', async () => {
// Mock out of stock response
nock('http://inventory-service:3001')
.get('/api/products/ABC123/stock')
.reply(200, { productId: 'ABC123', inStock: 0 });
const response = await request(orderService)
.post('/api/orders')
.send({
productId: 'ABC123',
quantity: 2
});
expect(response.status).toBe(400);
expect(response.body.error).toContain('out of stock');
});
it('should handle inventory service timeout', async () => {
// Mock timeout
nock('http://inventory-service:3001')
.get('/api/products/ABC123/stock')
.delayConnection(5000)
.reply(200, { inStock: 10 });
const response = await request(orderService)
.post('/api/orders')
.send({ productId: 'ABC123', quantity: 2 });
expect(response.status).toBe(503);
expect(response.body.error).toContain('Service unavailable');
});
});✓ Testing Async Communication (Message Queues)
Test services that communicate via RabbitMQ, Kafka, or other message brokers.
// Scenario: Order Service publishes event, Email Service consumes
describe('Order Event Publishing', () => {
it('should publish order.created event', async () => {
const eventSpy = jest.fn();
// Subscribe to events
messageQueue.subscribe('order.created', eventSpy);
// Create order
await request(orderService)
.post('/api/orders')
.send({ productId: 'ABC', quantity: 1 });
// Wait for async event
await waitFor(() => expect(eventSpy).toHaveBeenCalled());
const event = eventSpy.mock.calls[0][0];
expect(event.type).toBe('order.created');
expect(event.data.orderId).toBeDefined();
});
});
describe('Email Service Consumer', () => {
it('should send email on order.created event', async () => {
// Mock email service
const emailSpy = jest.spyOn(emailService, 'send');
// Publish test event
await messageQueue.publish('order.created', {
orderId: '123',
customerEmail: '[email protected]',
total: 99.99
});
// Wait for consumer to process
await waitFor(() => expect(emailSpy).toHaveBeenCalled());
expect(emailSpy).toHaveBeenCalledWith({
to: '[email protected]',
subject: 'Order Confirmation',
body: expect.stringContaining('Order #123')
});
});
});Integration Testing Best Practices
✓ DO
- ✓Use contract tests for service boundaries
- ✓Mock external third-party APIs
- ✓Isolate tests with transactions or cleanup
- ✓Use factories for test data generation
- ✓Test error scenarios and timeouts
- ✓Run integration tests in CI before deployment
- ✓Keep integration tests fast (under 1 minute)
✗ DON'T
- ✗Hit real external APIs in tests
- ✗Share test data between tests
- ✗Rely only on E2E tests
- ✗Use production database for testing
- ✗Make tests depend on test order
- ✗Ignore test flakiness
- ✗Skip cleanup after tests
Related Tools & Resources
External References
Official Documentation & Tools
- Pact Documentation - Consumer-driven contract testing
- Mock Service Worker (MSW) - API mocking library
- Nock - HTTP mocking for Node.js
- WireMock - Standalone mock API server
- Testcontainers - Disposable test databases with Docker
- Faker.js - Generate fake test data
Conclusion
Integration and contract testing bridge the gap between fast unit tests and slow end-to-end tests. Contract testing with Pact ensures services communicate correctly without needing all services running. API mocking lets you test third-party integrations without hitting real services. Good test data management keeps tests isolated and reliable.
For microservices, use the testing pyramid: lots of unit tests, some contract and integration tests, few E2E tests. Mock external dependencies but test real database interactions. Use transactions or testcontainers for test isolation. Remember: the goal is fast, reliable feedback. Contract tests give you confidence that services work together without the complexity of spinning up entire environments. Start with consumer-driven contracts for critical service boundaries, then expand your integration test coverage as needed.