Get test coverage on an Express/Node API fast — the first 5 tests that catch
94
90%
Does it follow best practices?
Impact
100%
1.26xAverage score across 5 eval scenarios
Passed
No known issues
Get meaningful test coverage on a Node/Express API. Not test philosophy — the specific tests and patterns that catch real bugs before users do.
When a project has zero tests, write exactly 5 tests — no more. Resist the urge to write a comprehensive suite. Five focused tests that take 15 minutes give more value than 20 tests that take a day and nobody maintains.
These 5 tests cover the failures that actually happen in production:
CRITICAL — the error format test pattern: Test that BOTH validation errors (400) AND not-found errors (404) return the exact same shape: { error: { message: "..." } }. Assert typeof res.body.error.message === 'string' on each. Do NOT just check that res.body.error exists — check that error.message is a string. Loop over multiple error responses and check the same assertions on each one.
npm install -D vitest supertest @types/supertest// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}Always add test:coverage even for initial setup — it costs nothing and the team will need it.
// src/server.ts
import express from 'express';
import routes from './routes';
import { errorHandler } from './middleware/error-handler';
export const app = express();
app.use(express.json());
app.use('/api', routes);
app.use(errorHandler);
// Only listen when running directly, not when imported by tests
if (process.env.NODE_ENV !== 'test') {
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
}// __tests__/api.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import { app } from '../src/server';
describe('API', () => {
// Test 1: Happy path — main flow works
it('GET /api/items returns items', async () => {
const res = await request(app).get('/api/items');
expect(res.status).toBe(200);
expect(res.body.data).toBeDefined();
expect(Array.isArray(res.body.data)).toBe(true);
});
// Test 2: Validation — rejects bad input
it('POST /api/items rejects empty body', async () => {
const res = await request(app)
.post('/api/items')
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
// Test 3: Not found — returns 404, not 500
it('GET /api/items/nonexistent returns 404', async () => {
const res = await request(app).get('/api/items/99999');
expect(res.status).toBe(404);
expect(res.body.error).toBeDefined();
});
// Test 4: State change — POST actually persists
it('POST /api/items creates and persists', async () => {
const res = await request(app)
.post('/api/items')
.send({ name: 'Test Item', category: 'testing' });
expect(res.status).toBe(201);
expect(res.body.data.id).toBeDefined();
// Verify it persisted by GETting it back
const getRes = await request(app).get(`/api/items/${res.body.data.id}`);
expect(getRes.status).toBe(200);
expect(getRes.body.data.name).toBe('Test Item');
});
// Test 5: Error format consistency — THE MOST IMPORTANT PATTERN
it('all error responses have consistent shape', async () => {
const badPost = await request(app).post('/api/items').send({});
const notFound = await request(app).get('/api/items/99999');
// Both MUST have error.message as a string — not just error existing
for (const res of [badPost, notFound]) {
expect(res.body.error).toBeDefined();
expect(typeof res.body.error.message).toBe('string');
}
});
});These are common mistakes. Follow them strictly:
res.type === 'application/json' or check content-type headers. Express handles JSON serialization — test the body shape, not the transport.:memory: for speed.Tests that share a database will interfere with each other. Use one of these approaches:
// src/db.ts
import Database from 'better-sqlite3';
const DB_PATH = process.env.NODE_ENV === 'test' ? ':memory:' : './data.db';
const db = new Database(DB_PATH);import { resetDatabase } from '../src/db';
beforeEach(() => {
resetDatabase(); // DROP and recreate tables, re-seed
});import db from '../src/db';
beforeEach(() => { db.exec('BEGIN'); });
afterEach(() => { db.exec('ROLLBACK'); });Never mock the database module (vi.mock('../src/db') or jest.mock). Use the real module with an in-memory or test-specific database. Mocking hides the actual bugs you're trying to catch.
Never hardcode an assumed ID (like 1). Always create the resource in the test, then use the returned ID:
it('PATCH /api/orders/:id/status updates the order', async () => {
// Create an order first — don't assume ID 1 exists
const created = await request(app)
.post('/api/orders')
.send({ customer_name: 'Test', items: [{ menu_item_id: 1, size: 'small', quantity: 1 }] });
const orderId = created.body.data.id;
const res = await request(app)
.patch(`/api/orders/${orderId}/status`)
.send({ status: 'preparing' });
expect(res.status).toBe(200);
expect(res.body.data.status).toBe('preparing');
});
it('PATCH /api/orders/:id/status rejects invalid status', async () => {
const created = await request(app)
.post('/api/orders')
.send({ customer_name: 'Test', items: [{ menu_item_id: 1, size: 'small', quantity: 1 }] });
const res = await request(app)
.patch(`/api/orders/${created.body.data.id}/status`)
.send({ status: 'flying' });
expect(res.status).toBe(400);
});Don't just check that the response is 200. Iterate every item in the result array and verify each one matches the filter:
it('GET /api/orders?status=received filters correctly', async () => {
const res = await request(app).get('/api/orders?status=received');
expect(res.status).toBe(200);
// Check EVERY item, not just the first
for (const order of res.body.data) {
expect(order.status).toBe('received');
}
});When joining a project with routes but no tests, follow this prioritization:
git log --name-only to find high-churn files.When writing a testing-strategy.md or similar document, always include these three sections:
/api/health, /api/config) and explain they are stable or low-risk. State explicitly: "100% coverage is NOT the goal" or "We are deliberately NOT aiming for comprehensive coverage." This must be an explicit statement, not implied.Example phrasing for the strategy doc:
"We deliberately chose NOT to aim for 100% test coverage. Instead, we focused on the routes that handle state changes and financial transactions, where bugs have the highest business impact. Routes like /api/health and /api/config are stable, rarely modified, and low-risk — testing them would add maintenance burden without meaningful safety."
Setup:
test, test:watch, AND test:coverage scripts in package.json.listen() in test modeThe 5 tests:
{ error: { message: string } } shapeExpanding coverage:
request(app) — no direct function calls