Testing Strategies for Node.js APIs: Unit, Integration, and E2E
Introduction
APIs are contracts—clients depend on stable behavior, error shapes, and performance. Testing Node.js APIs at multiple layers catches regressions early: pure business logic in unit tests, HTTP + database in integration tests, and critical flows end-to-end against real or near-real environments.
This guide defines a practical test pyramid for Express/Fastify services: what to mock, what not to mock, database strategies, authentication in tests, and running suites in CI with parallelism and isolation.
Table of Contents
- The Test Pyramid for APIs
- Unit Testing Services
- Integration Tests with Supertest
- Database Test Strategies
- Mocking External Services
- Authentication and Authorization Tests
- CI Configuration and Coverage
- Best Practices
- Common Mistakes
- Interview Questions
- Summary
- References
The Test Pyramid for APIs
/ E2E \
/ few \
/ integration \
/ many \
/ unit (many) \
- Unit: services, validators, pure functions—milliseconds, no I/O
- Integration: HTTP routes + real DB (or test DB)—catch wiring bugs
- E2E: deploy preview, Playwright/curl against staging—slow, high confidence
Most API teams overweight E2E and underweight integration—integration tests give the best ROI for HTTP APIs.
Unit Testing Services
Test business rules without HTTP or database:
import { calculateDiscount } from "../services/pricing.service";
describe("calculateDiscount", () => {
it("applies percentage for PRO users", () => {
expect(calculateDiscount({ plan: "PRO", total: 100 })).toBe(90);
});
it("returns zero discount for FREE plan", () => {
expect(calculateDiscount({ plan: "FREE", total: 100 })).toBe(100);
});
});
Mock repositories when testing services:
const mockUserRepo = {
findById: jest.fn().mockResolvedValue({ id: "1", role: "admin" }),
};
const service = new UserService(mockUserRepo);
await service.deleteUser("1", "requester-1");
expect(mockUserRepo.findById).toHaveBeenCalledWith("requester-1");
Integration Tests with Supertest
Spin up app without listening on network, or use ephemeral port:
import request from "supertest";
import { createApp } from "../app";
import { db } from "../lib/db";
const app = createApp();
beforeAll(async () => {
await db.migrate.latest();
});
afterEach(async () => {
await db("users").del();
});
describe("POST /api/users", () => {
it("creates user and returns 201", async () => {
const res = await request(app)
.post("/api/users")
.send({ email: "test@example.com", name: "Test" })
.expect(201);
expect(res.body.email).toBe("test@example.com");
expect(res.body.id).toBeDefined();
});
it("returns 400 for invalid email", async () => {
await request(app)
.post("/api/users")
.send({ email: "bad", name: "Test" })
.expect(400);
});
});
Export createApp() from factory—don't import server that calls listen() on import.
Database Test Strategies
Option 1: Test database (PostgreSQL)
process.env.DATABASE_URL = "postgresql://test:test@localhost:5433/app_test";
Run migrations before suite; truncate tables between tests.
Option 2: SQLite in-memory for speed—schema parity risks.
Option 3: Transaction rollback per test
beforeEach(async () => {
await db.transaction(async (trx) => {
trxRef = trx;
// bind repos to trx for this test
});
});
afterEach(async () => trxRef.rollback());
Use Testcontainers for CI parity with production DB:
const container = await new PostgreSqlContainer().start();
process.env.DATABASE_URL = container.getConnectionUri();
Mocking External Services
Don't call Stripe or SendGrid in tests—mock HTTP with MSW or nock:
import nock from "nock";
nock("https://api.stripe.com")
.post("/v1/customers")
.reply(200, { id: "cus_test" });
Verify your code handles timeouts and 5xx:
nock("https://api.example.com").get("/data").reply(503);
await expect(fetchExternal()).rejects.toThrow("Service unavailable");
Authentication and Authorization Tests
Generate test tokens:
function authHeader(userId: string, role: string) {
const token = jwt.sign({ sub: userId, role }, process.env.JWT_SECRET!);
return { Authorization: `Bearer ${token}` };
}
it("forbids non-admin from deleting users", async () => {
await request(app)
.delete("/api/users/2")
.set(authHeader("1", "user"))
.expect(403);
});
Test 401 without token, 403 with wrong role, 200 with correct permissions.
CI Configuration and Coverage
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:ci
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/test
{
"scripts": {
"test": "jest",
"test:ci": "jest --ci --coverage --maxWorkers=2"
}
}
Set coverage thresholds on services layer—not chase 100% on routes.
Best Practices
- Factory for app—inject config and db for tests.
- Deterministic data—factories (faker with fixed seed) not random without reset.
- One behavior per test—clear failure messages.
- Run integration tests in CI on every PR.
- Separate fast unit job from slower integration for developer feedback.
- Test error responses—shape and status codes, not only happy path.
Common Mistakes
- Sharing DB state between tests without cleanup—flaky order-dependent failures.
- Mocking the database in integration tests—misses SQL bugs.
- Testing through full stack only—slow feedback, hard to debug.
- Hardcoded ports—parallel test workers collide.
- No tests for validation middleware—400 paths untested.
- Sleep instead of await—flaky timing tests.
Interview Questions
- Explain test pyramid for backend APIs.
- Unit vs integration test—what to mock?
- How to test authenticated endpoints?
- Strategies for database isolation in tests?
- What is Supertest and how does it work?
- How to test external API failures?
- Tradeoffs of Testcontainers?
- What coverage threshold is reasonable?
Load and Performance Testing
Complement functional tests with load tests for critical endpoints:
// k6 script excerpt
import http from "k6/http";
import { check, sleep } from "k6";
export const options = { vus: 50, duration: "30s" };
export default function () {
const res = http.get("https://api.staging.example.com/health");
check(res, { "status is 200": (r) => r.status === 200 });
sleep(1);
}
Run load tests against staging before major releases—not in unit test suite, but in nightly or pre-release pipeline.
Establish SLO baselines: p95 latency and error rate under expected peak VUs.
Test Data Management
Use factories for consistent entities:
import { faker } from "@faker-js/faker";
export function buildUser(overrides = {}) {
return {
email: faker.internet.email(),
name: faker.person.fullName(),
...overrides,
};
}
Seed reference data once per suite; generate ephemeral data per test for isolation.
Never use production data snapshots in tests—PII and compliance risk.
Flaky Test Discipline
Quarantine flaky tests—do not ignore:
- Reproduce locally with
--runInBandand repeated runs - Fix timing issues with proper
awaitand polling helpers - Remove shared global state
- If environment-dependent, mark explicit
describe.skipwith ticket link—not random retries
jest.retryTimes is a last resort, not a strategy.
Contract Testing with Pact
Consumer-driven contracts verify API responses match client expectations:
import { PactV3 } from "@pact-foundation/pact";
const provider = new PactV3({
consumer: "WebApp",
provider: "UserAPI",
});
await provider
.given("user exists")
.uponReceiving("get user")
.withRequest({ method: "GET", path: "/users/1" })
.willRespondWith({ status: 200, body: { id: "1", name: "Test" } })
.executeTest(async (mockServer) => {
const user = await apiGet(`${mockServer.url}/users/1`);
expect(user.name).toBe("Test");
});
Provider verifies pact in CI—breaking changes fail before deploy.
Maintain test coverage reports in CI artifacts—trend line dropping over sprints signals tests deleted without replacement or new code paths lacking integration coverage. Coverage percentage alone misleads; combine with mutation testing on critical payment and auth modules for higher confidence that assertions actually validate behavior rather than merely executing lines without meaningful checks on outcomes.
Summary
Reliable Node.js API testing layers unit tests on services, integration tests on HTTP + database, and selective E2E. Use Supertest, isolated test databases, MSW/nock for externals, and CI services for Postgres. Fast feedback from integration tests prevents most production regressions without drowning in flaky E2E.