Series Navigation: This is Part 15 of the 17-part API Development Series. Review Part 14: GraphQL & gRPC first.
API Development Mastery
Your 17-step learning path • Currently on Step 15
Backend API Fundamentals
REST, HTTP, status codes, URI designData Layer & Persistence
Database integration, CRUD, transactions, RedisOpenAPI Specification
Contract-first design, OpenAPI 3.0/3.1Documentation & DX
Swagger UI, Redoc, developer portalsAuthentication & Authorization
OAuth 2.0, JWT, RBAC, ABACSecurity Hardening
OWASP Top 10, input validation, CORSAWS API Gateway
REST/HTTP APIs, Lambda integration, WAFAzure API Management
Policies, products, developer portalGCP Apigee
API proxies, monetization, analyticsArchitecture Patterns
Gateway, BFF, microservices, DDDVersioning & Governance
SemVer, deprecation, lifecycleMonitoring & Analytics
Observability, tracing, SLIs/SLOsPerformance & Rate Limiting
Caching, throttling, load testingGraphQL & gRPC
Alternative API styles, Protocol Buffers15
Testing & Contracts
Contract testing, Pact, Postman/Newman16
CI/CD & Automation
Spectral, GitHub Actions, Terraform17
API Product Management
API as Product, monetization, ecosystemsAPI Testing Pyramid
The Testing Pyramid for APIs
Effective API testing requires a balanced approach across multiple layers, from fast unit tests to comprehensive end-to-end tests.
API Testing Pyramid
| Layer | Quantity | Speed | What It Tests |
|---|---|---|---|
| Unit | Many (70%) | Fast (ms) | Business logic, validators, transformers |
| Integration | Some (20%) | Medium (s) | Database, external services, middleware |
| Contract | Key APIs | Medium | API shape, consumer expectations |
| E2E | Few (10%) | Slow (min) | Full user journeys, critical paths |
Unit Testing APIs
Testing Business Logic
// taskService.js
class TaskService {
constructor(taskRepository) {
this.taskRepository = taskRepository;
}
async createTask(data, userId) {
if (!data.title?.trim()) {
throw new ValidationError('Title is required');
}
const task = {
...data,
id: generateId(),
ownerId: userId,
status: data.status || 'pending',
createdAt: new Date()
};
return this.taskRepository.save(task);
}
}
// taskService.test.js
const { TaskService } = require('./taskService');
const { ValidationError } = require('./errors');
describe('TaskService', () => {
let taskService;
let mockRepository;
beforeEach(() => {
mockRepository = {
save: jest.fn(task => Promise.resolve({ ...task, id: 'task_123' })),
findById: jest.fn()
};
taskService = new TaskService(mockRepository);
});
describe('createTask', () => {
it('should create a task with default status', async () => {
const task = await taskService.createTask(
{ title: 'Test task' },
'user_1'
);
expect(task.title).toBe('Test task');
expect(task.status).toBe('pending');
expect(task.ownerId).toBe('user_1');
expect(mockRepository.save).toHaveBeenCalledTimes(1);
});
it('should throw ValidationError for empty title', async () => {
await expect(
taskService.createTask({ title: '' }, 'user_1')
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError for whitespace-only title', async () => {
await expect(
taskService.createTask({ title: ' ' }, 'user_1')
).rejects.toThrow(ValidationError);
});
});
});
Integration Testing
Testing with Real Database
// tasks.integration.test.js
const request = require('supertest');
const { createApp } = require('../app');
const { setupTestDb, teardownTestDb, getTestDb } = require('./testUtils');
describe('Tasks API Integration', () => {
let app;
let db;
let authToken;
beforeAll(async () => {
db = await setupTestDb();
app = createApp({ db });
// Get auth token for tests
const authRes = await request(app)
.post('/auth/login')
.send({ email: 'test@example.com', password: 'password123' });
authToken = authRes.body.accessToken;
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
// Clean tasks table between tests
await db.query('DELETE FROM tasks');
});
describe('POST /tasks', () => {
it('should create a task and return 201', async () => {
const response = await request(app)
.post('/tasks')
.set('Authorization', `Bearer ${authToken}`)
.send({ title: 'Integration test task', priority: 'high' });
expect(response.status).toBe(201);
expect(response.body.id).toBeDefined();
expect(response.body.title).toBe('Integration test task');
expect(response.headers.location).toMatch(/\/tasks\/task_/);
// Verify in database
const dbTask = await db.query(
'SELECT * FROM tasks WHERE id = $1',
[response.body.id]
);
expect(dbTask.rows[0].title).toBe('Integration test task');
});
it('should return 422 for invalid data', async () => {
const response = await request(app)
.post('/tasks')
.set('Authorization', `Bearer ${authToken}`)
.send({ title: '' });
expect(response.status).toBe(422);
expect(response.body.errors).toBeDefined();
});
it('should return 401 without auth token', async () => {
const response = await request(app)
.post('/tasks')
.send({ title: 'Unauthorized task' });
expect(response.status).toBe(401);
});
});
describe('GET /tasks', () => {
beforeEach(async () => {
// Seed test data
await db.query(`
INSERT INTO tasks (id, title, status, owner_id)
VALUES
('task_1', 'Task 1', 'pending', 'user_1'),
('task_2', 'Task 2', 'completed', 'user_1'),
('task_3', 'Task 3', 'pending', 'user_1')
`);
});
it('should return paginated tasks', async () => {
const response = await request(app)
.get('/tasks?limit=2')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(response.body.data).toHaveLength(2);
expect(response.body.pagination.hasMore).toBe(true);
});
it('should filter by status', async () => {
const response = await request(app)
.get('/tasks?status=completed')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0].status).toBe('completed');
});
});
});
Contract Testing
Consumer-Driven Contract Testing with Pact
Contract Testing: Ensures API consumers and providers agree on the API shape. Contracts are generated by consumers and verified by providers.
// consumer.pact.test.js - Frontend/Consumer side
const { PactV3 } = require('@pact-foundation/pact');
const { TaskApiClient } = require('./taskApiClient');
const provider = new PactV3({
consumer: 'TaskWebApp',
provider: 'TaskAPI'
});
describe('Task API Contract', () => {
it('should get a task by ID', async () => {
// Define expected interaction
provider
.given('a task with ID task_123 exists')
.uponReceiving('a request to get task task_123')
.withRequest({
method: 'GET',
path: '/tasks/task_123',
headers: { 'Authorization': 'Bearer valid-token' }
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: 'task_123',
title: Matchers.string('Sample task'),
status: Matchers.term({
matcher: 'pending|in_progress|completed',
generate: 'pending'
}),
createdAt: Matchers.iso8601DateTime()
}
});
// Run test against mock provider
await provider.executeTest(async (mockProvider) => {
const client = new TaskApiClient(mockProvider.url);
const task = await client.getTask('task_123');
expect(task.id).toBe('task_123');
expect(task.status).toBe('pending');
});
});
it('should create a task', async () => {
provider
.uponReceiving('a request to create a task')
.withRequest({
method: 'POST',
path: '/tasks',
headers: {
'Authorization': 'Bearer valid-token',
'Content-Type': 'application/json'
},
body: {
title: 'New task'
}
})
.willRespondWith({
status: 201,
headers: {
'Content-Type': 'application/json',
'Location': Matchers.term({
matcher: '/tasks/task_.*',
generate: '/tasks/task_456'
})
},
body: {
id: Matchers.uuid(),
title: 'New task',
status: 'pending'
}
});
await provider.executeTest(async (mockProvider) => {
const client = new TaskApiClient(mockProvider.url);
const task = await client.createTask({ title: 'New task' });
expect(task.title).toBe('New task');
});
});
});
Provider Verification
// provider.pact.test.js - Backend/Provider side
const { Verifier } = require('@pact-foundation/pact');
const { createApp } = require('../app');
describe('Pact Provider Verification', () => {
let server;
beforeAll(async () => {
const app = createApp();
server = app.listen(4000);
});
afterAll(() => server.close());
it('should validate the contract', async () => {
const verifier = new Verifier({
providerBaseUrl: 'http://localhost:4000',
pactUrls: ['./pacts/TaskWebApp-TaskAPI.json'],
// Or fetch from Pact Broker
// pactBrokerUrl: 'https://pact-broker.example.com',
stateHandlers: {
'a task with ID task_123 exists': async () => {
// Set up test data
await db.query(`
INSERT INTO tasks (id, title, status)
VALUES ('task_123', 'Sample task', 'pending')
`);
}
}
});
await verifier.verifyProvider();
});
});
Postman & Newman
Automated API Testing with Postman
{
"info": {
"name": "Task API Tests",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Create Task",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status code is 201', () => {",
" pm.response.to.have.status(201);",
"});",
"",
"pm.test('Response has task ID', () => {",
" const json = pm.response.json();",
" pm.expect(json.id).to.match(/^task_/);",
" pm.environment.set('taskId', json.id);",
"});",
"",
"pm.test('Response time under 500ms', () => {",
" pm.expect(pm.response.responseTime).to.be.below(500);",
"});"
]
}
}
],
"request": {
"method": "POST",
"url": "{{baseUrl}}/tasks",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\"title\": \"Postman test task\"}"
}
}
}
]
}
Running with Newman in CI
# Install Newman
npm install -g newman newman-reporter-htmlextra
# Run collection
newman run postman-collection.json \
--environment production.postman_environment.json \
--reporters cli,htmlextra \
--reporter-htmlextra-export ./reports/api-test-report.html \
--bail # Stop on first failure
API Mocking
Mock Server with Prism
# Start mock server from OpenAPI spec
npx @stoplight/prism-cli mock openapi.yaml -p 4010
# Dynamic mocking with examples
curl http://localhost:4010/tasks
# Returns example from OpenAPI spec
# Force specific response
curl -H "Prefer: code=404" http://localhost:4010/tasks/invalid
MSW for Frontend Testing
// mocks/handlers.js
import { rest } from 'msw';
export const handlers = [
rest.get('/api/tasks', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
data: [
{ id: 'task_1', title: 'Mock task 1', status: 'pending' },
{ id: 'task_2', title: 'Mock task 2', status: 'completed' }
],
pagination: { hasMore: false }
})
);
}),
rest.post('/api/tasks', async (req, res, ctx) => {
const body = await req.json();
return res(
ctx.status(201),
ctx.json({
id: 'task_new',
...body,
status: 'pending',
createdAt: new Date().toISOString()
})
);
})
];
Practice Exercises
Exercise 1: Unit Tests
- Write unit tests for your service layer
- Mock repository dependencies
- Test edge cases and error scenarios
Exercise 2: Pact Contract Tests
- Set up Pact for consumer contract tests
- Define contracts for 3 key endpoints
- Implement provider verification
Exercise 3: Postman + Newman CI
- Create comprehensive Postman collection
- Add test scripts with assertions
- Set up Newman in GitHub Actions
- Generate HTML test reports
Next Steps: In Part 16: CI/CD & Automation, we'll build automated pipelines with Spectral linting, GitHub Actions workflows, and Terraform deployments.