Most API test suites are theater. They test that 200 OK is returned for valid input, but miss the edge cases that break production — race conditions, partial failures, invalid state transitions, and subtle serialization bugs.
Here's a testing strategy built from real production incidents, organized by what each test type catches and how much it costs to maintain.
The Testing Pyramid for APIs
/ E2E \ — 5% of tests, catch integration failures
/ Contract \ — 15%, catch API compatibility breaks
/ Integration \ — 30%, catch database + service bugs
/ Unit Tests \ — 50%, catch logic bugs fast
Project Setup
mkdir api-testing && cd api-testing
npm init -y
npm install express zod drizzle-orm better-sqlite3
npm install -D vitest supertest @types/supertest testcontainers msw
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
exclude: ['**/test/**', '**/*.test.ts'],
},
// Run tests in sequence for integration tests
pool: 'forks',
poolOptions: {
forks: { singleFork: true },
},
},
});
Unit Tests: Pure Logic, No I/O
Unit tests should cover business logic — validation, transformations, calculations. No database, no HTTP, no file system.
// src/services/pricing.ts
export function calculateDiscount(
basePrice: number,
quantity: number,
customerTier: 'standard' | 'premium' | 'enterprise'
): { price: number; discount: number; total: number } {
if (basePrice < 0 || quantity < 1) {
throw new Error('Invalid input');
}
const tierMultipliers = { standard: 0, premium: 0.1, enterprise: 0.2 };
const volumeDiscount = quantity >= 100 ? 0.15 : quantity >= 10 ? 0.05 : 0;
const discount = Math.min(tierMultipliers[customerTier] + volumeDiscount, 0.35);
const total = basePrice * quantity * (1 - discount);
return { price: basePrice, discount, total: Math.round(total * 100) / 100 };
}
// src/services/pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from './pricing';
describe('calculateDiscount', () => {
it('applies no discount for standard tier, small quantity', () => {
const result = calculateDiscount(100, 5, 'standard');
expect(result).toEqual({ price: 100, discount: 0, total: 500 });
});
it('caps combined discount at 35%', () => {
// Enterprise (20%) + volume 100+ (15%) = 35% cap
const result = calculateDiscount(100, 100, 'enterprise');
expect(result.discount).toBe(0.35);
expect(result.total).toBe(6500);
});
it('stacks tier and volume discounts', () => {
// Premium (10%) + volume 10+ (5%) = 15%
const result = calculateDiscount(50, 20, 'premium');
expect(result.discount).toBe(0.15);
expect(result.total).toBe(850);
});
it('rejects negative prices', () => {
expect(() => calculateDiscount(-10, 1, 'standard')).toThrow('Invalid input');
});
it('rejects zero quantity', () => {
expect(() => calculateDiscount(100, 0, 'standard')).toThrow('Invalid input');
});
// This test caught a real bug — floating point precision
it('rounds total to 2 decimal places', () => {
const result = calculateDiscount(19.99, 3, 'premium');
// Without rounding: 19.99 * 3 * 0.9 = 53.973
expect(result.total).toBe(53.97);
});
});
What unit tests catch: Logic errors, edge cases, floating point bugs, off-by-one errors. They're fast (run in <1ms each) and stable (no external dependencies).
Integration Tests: Real Database, Real Queries
Integration tests hit the actual database. Mocking SQL queries is worse than useless — it tests that your mock matches your assumptions, not that your query works.
// src/repositories/user.integration.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { users } from '../schema';
import { UserRepository } from './user';
describe('UserRepository', () => {
let db: ReturnType<typeof drizzle>;
let sqlite: Database.Database;
let repo: UserRepository;
beforeAll(() => {
sqlite = new Database(':memory:');
db = drizzle(sqlite);
migrate(db, { migrationsFolder: './drizzle' });
repo = new UserRepository(db);
});
afterAll(() => {
sqlite.close();
});
beforeEach(() => {
// Clean slate for each test
db.delete(users).run();
});
it('creates a user and retrieves by ID', async () => {
const created = await repo.create({
email: 'test@example.com',
name: 'Test User',
});
expect(created.id).toBeDefined();
expect(created.email).toBe('test@example.com');
const found = await repo.findById(created.id);
expect(found).toEqual(created);
});
it('enforces unique email constraint', async () => {
await repo.create({ email: 'dup@example.com', name: 'User 1' });
await expect(
repo.create({ email: 'dup@example.com', name: 'User 2' })
).rejects.toThrow(/UNIQUE constraint/);
});
it('handles pagination correctly at boundaries', async () => {
// Create exactly 25 users
for (let i = 0; i < 25; i++) {
await repo.create({ email: `user${i}@test.com`, name: `User ${i}` });
}
const page1 = await repo.list({ page: 1, limit: 10 });
const page3 = await repo.list({ page: 3, limit: 10 });
expect(page1.items).toHaveLength(10);
expect(page3.items).toHaveLength(5);
expect(page3.totalPages).toBe(3);
});
it('soft-deletes without removing from database', async () => {
const user = await repo.create({ email: 'del@test.com', name: 'Delete Me' });
await repo.softDelete(user.id);
// findById should not return soft-deleted users
const found = await repo.findById(user.id);
expect(found).toBeNull();
// But the row still exists (for audit/recovery)
const raw = sqlite.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
expect(raw).toBeDefined();
expect((raw as any).deleted_at).not.toBeNull();
});
});
What integration tests catch: SQL bugs, constraint violations, migration issues, ORM misuse, query performance regressions. Using SQLite in-memory is fast enough for most tests (sub-second for hundreds of queries).
API Tests: HTTP Layer + Middleware
Test the full HTTP stack — routing, middleware, serialization, error handling.
// src/app.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createApp } from './app';
describe('API endpoints', () => {
let app: ReturnType<typeof createApp>;
beforeAll(() => {
app = createApp({ database: ':memory:' });
});
describe('POST /api/users', () => {
it('creates user with valid input', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'new@test.com', name: 'New User' })
.expect(201);
expect(res.body).toMatchObject({
email: 'new@test.com',
name: 'New User',
});
expect(res.body.id).toBeDefined();
expect(res.body.createdAt).toBeDefined();
});
it('returns 400 with validation details for bad input', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'not-an-email', name: '' })
.expect(400);
expect(res.body.error).toBe('Validation failed');
expect(res.body.details).toHaveLength(2);
expect(res.body.details[0].path).toContain('email');
expect(res.body.details[1].path).toContain('name');
});
it('returns 409 for duplicate email', async () => {
await request(app)
.post('/api/users')
.send({ email: 'dup@test.com', name: 'First' });
const res = await request(app)
.post('/api/users')
.send({ email: 'dup@test.com', name: 'Second' })
.expect(409);
expect(res.body.error).toContain('already exists');
});
});
describe('GET /api/users', () => {
it('returns paginated results with correct headers', async () => {
// Seed some data
for (let i = 0; i < 15; i++) {
await request(app)
.post('/api/users')
.send({ email: `page${i}@test.com`, name: `User ${i}` });
}
const res = await request(app)
.get('/api/users?page=2&limit=10')
.expect(200);
expect(res.body.items).toHaveLength(5);
expect(res.body.pagination.page).toBe(2);
expect(res.body.pagination.totalPages).toBe(2);
});
it('ignores unknown query parameters', async () => {
await request(app)
.get('/api/users?foo=bar&page=1')
.expect(200);
});
});
describe('Authentication', () => {
it('returns 401 for missing token', async () => {
await request(app).get('/api/admin/stats').expect(401);
});
it('returns 401 for expired token', async () => {
const expiredToken = createExpiredJWT();
await request(app)
.get('/api/admin/stats')
.set('Authorization', `Bearer ${expiredToken}`)
.expect(401);
});
});
});
What API tests catch: Routing bugs, middleware ordering issues, status code mistakes, response format errors, authentication bypass.
Contract Tests: API Compatibility
When other services depend on your API, contract tests prevent breaking changes.
// src/contracts/user-api.contract.test.ts
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import request from 'supertest';
import { createApp } from '../app';
// This schema represents what consumers expect
const UserResponseContract = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
const PaginatedUsersContract = z.object({
items: z.array(UserResponseContract),
pagination: z.object({
page: z.number(),
limit: z.number(),
total: z.number(),
totalPages: z.number(),
}),
});
describe('User API contract', () => {
const app = createApp({ database: ':memory:' });
it('POST /api/users response matches contract', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'contract@test.com', name: 'Contract Test' });
const parsed = UserResponseContract.safeParse(res.body);
expect(parsed.success).toBe(true);
});
it('GET /api/users response matches paginated contract', async () => {
const res = await request(app).get('/api/users');
const parsed = PaginatedUsersContract.safeParse(res.body);
expect(parsed.success).toBe(true);
});
it('error responses always include error field', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'bad' });
expect(res.body).toHaveProperty('error');
expect(typeof res.body.error).toBe('string');
});
});
What contract tests catch: Accidental field renames, type changes, missing fields. They fail when you'd break a consumer.
Testing External Service Calls with MSW
Mock Service Worker intercepts HTTP calls at the network level — no mocking fetch or axios.
// src/services/payment.test.ts
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { PaymentService } from './payment';
const server = setupServer();
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('PaymentService', () => {
const service = new PaymentService('https://api.stripe.test');
it('creates a payment intent', async () => {
server.use(
http.post('https://api.stripe.test/v1/payment_intents', () => {
return HttpResponse.json({
id: 'pi_test_123',
status: 'requires_payment_method',
amount: 1000,
});
})
);
const result = await service.createPaymentIntent(1000, 'usd');
expect(result.id).toBe('pi_test_123');
expect(result.amount).toBe(1000);
});
it('retries on 503 then succeeds', async () => {
let attempts = 0;
server.use(
http.post('https://api.stripe.test/v1/payment_intents', () => {
attempts++;
if (attempts < 3) {
return new HttpResponse(null, { status: 503 });
}
return HttpResponse.json({ id: 'pi_retry', status: 'created', amount: 500 });
})
);
const result = await service.createPaymentIntent(500, 'usd');
expect(result.id).toBe('pi_retry');
expect(attempts).toBe(3);
});
it('throws after max retries exhausted', async () => {
server.use(
http.post('https://api.stripe.test/v1/payment_intents', () => {
return new HttpResponse(null, { status: 503 });
})
);
await expect(service.createPaymentIntent(500, 'usd')).rejects.toThrow(
'Payment service unavailable'
);
});
it('handles malformed JSON response', async () => {
server.use(
http.post('https://api.stripe.test/v1/payment_intents', () => {
return new HttpResponse('not json', {
headers: { 'Content-Type': 'application/json' },
});
})
);
await expect(service.createPaymentIntent(500, 'usd')).rejects.toThrow();
});
});
What MSW tests catch: Retry logic bugs, timeout handling, error mapping, response parsing failures.
Test Organization
src/
├── services/
│ ├── pricing.ts
│ └── pricing.test.ts # Unit tests: co-located
├── repositories/
│ ├── user.ts
│ └── user.integration.test.ts # Integration tests: co-located
├── app.test.ts # API tests: top-level
└── contracts/
└── user-api.contract.test.ts # Contract tests: separate dir
Run them separately:
{
"scripts": {
"test": "vitest run",
"test:unit": "vitest run --testPathPattern='.test.ts$' --testPathIgnorePatterns='integration|contract'",
"test:integration": "vitest run --testPathPattern='integration'",
"test:contract": "vitest run --testPathPattern='contract'",
"test:watch": "vitest watch"
}
}
What Not to Test
- Framework behavior: Don't test that Express returns 404 for unregistered routes. That's Express's job.
- Simple getters/setters: If a function just passes through data, the integration test covers it.
- Third-party library internals: Don't test that Zod validates emails correctly.
- Implementation details: Test the output, not how a function internally computes it.
Conclusion
The strategy is straightforward:
- Unit tests for business logic and calculations (fast, numerous)
- Integration tests for database operations (use real databases, not mocks)
- API tests for HTTP behavior and middleware (use supertest)
- Contract tests for API stability (use Zod schemas)
- MSW for external service interactions (network-level mocking)
Each layer catches different bugs. Skip a layer and those bugs reach production.
If this was helpful, you can support my work at ko-fi.com/nopkt
If this article helped you, consider buying me a coffee on Ko-fi! Follow me for more production backend patterns.
You Might Also Like
- BullMQ Job Queues in Node.js: Background Processing Done Right (2026 Guide)
- Role-Based Access Control (RBAC) in Node.js: Beyond Simple Admin Checks (2026)
- Zod vs Joi vs Class-Validator: Input Validation in TypeScript APIs Compared (2026)
Follow me for more production-ready backend content!
If this helped you, buy me a coffee on Ko-fi!
Top comments (0)