Test Express APIs with supertest
How to test Express.js REST API endpoints end-to-end using supertest, including status codes, JSON bodies, headers, authentication, and error handling.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
Supertest is a Node.js library for testing HTTP servers. It wraps the superagent HTTP client and lets you make requests against your Express app without starting a real server. Requests go through the Express routing stack, middleware, and handlers — making it an end-to-end test for your API layer.
When to Use
- Testing Express route handlers, middleware, and error handlers together
- Verifying HTTP status codes, response bodies, and headers from API endpoints
- Testing authentication and authorization flows (JWT, session, API keys)
- Validating request validation (body, params, query) and error response format
- Testing API rate limiting and content negotiation
When NOT to Use
- Unit testing individual functions — use
vitestorjestdirectly - Testing frontend components — use
@testing-library/reactor@testing-library/vue - Load testing — use
k6orartilleryinstead - Testing external APIs — use
nockormswto mock them
Solution
Setup
npm install -D supertest vitest
Basic API test
import request from "supertest";
import express from "express";
import { describe, it, expect } from "vitest";
const app = express();
app.use(express.json());
app.get("/api/health", (req, res) => {
res.json({ status: "healthy", timestamp: Date.now() });
});
describe("GET /api/health", () => {
it("returns 200 with healthy status", async () => {
const response = await request(app).get("/api/health");
expect(response.status).toBe(200);
expect(response.body.status).toBe("healthy");
expect(response.body.timestamp).toBeDefined();
});
});
Testing POST with JSON body
app.post("/api/users", (req, res) => {
const { email, name } = req.body;
if (!email || !name) {
return res.status(400).json({ error: "email and name are required" });
}
res.status(201).json({ id: 1, email, name });
});
describe("POST /api/users", () => {
it("creates a user with valid data", async () => {
const response = await request(app)
.post("/api/users")
.send({ email: "alice@example.com", name: "Alice" })
.set("Content-Type", "application/json");
expect(response.status).toBe(201);
expect(response.body.id).toBe(1);
expect(response.body.email).toBe("alice@example.com");
});
it("returns 400 when email is missing", async () => {
const response = await request(app)
.post("/api/users")
.send({ name: "Alice" })
.set("Content-Type", "application/json");
expect(response.status).toBe(400);
expect(response.body.error).toContain("email");
});
});
Testing authentication
import jwt from "jsonwebtoken";
const SECRET = "test-secret";
app.get("/api/profile", (req, res) => {
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Unauthorized" });
}
const token = auth.slice(7);
try {
const payload = jwt.verify(token, SECRET);
res.json({ userId: payload.userId, email: payload.email });
} catch {
res.status(401).json({ error: "Invalid token" });
}
});
describe("GET /api/profile with auth", () => {
it("returns 401 without token", async () => {
const response = await request(app).get("/api/profile");
expect(response.status).toBe(401);
});
it("returns user data with valid token", async () => {
const token = jwt.sign({ userId: 42, email: "alice@example.com" }, SECRET);
const response = await request(app)
.get("/api/profile")
.set("Authorization", `Bearer ${token}`);
expect(response.status).toBe(200);
expect(response.body.userId).toBe(42);
});
it("returns 401 with expired token", async () => {
const token = jwt.sign({ userId: 42 }, SECRET, { expiresIn: "0s" });
const response = await request(app)
.get("/api/profile")
.set("Authorization", `Bearer ${token}`);
expect(response.status).toBe(401);
});
});
Testing query parameters and pagination
const users = Array.from({ length: 25 }, (_, i) => ({
id: i + 1,
name: `User ${i + 1}`,
}));
app.get("/api/users", (req, res) => {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const start = (page - 1) * limit;
const end = start + limit;
res.json({
data: users.slice(start, end),
total: users.length,
page,
limit,
});
});
describe("GET /api/users with pagination", () => {
it("returns first page by default", async () => {
const response = await request(app).get("/api/users");
expect(response.status).toBe(200);
expect(response.body.data).toHaveLength(10);
expect(response.body.page).toBe(1);
expect(response.body.total).toBe(25);
});
it("returns correct page when specified", async () => {
const response = await request(app).get("/api/users?page=2&limit=5");
expect(response.body.data).toHaveLength(5);
expect(response.body.data[0].id).toBe(6);
});
});
Testing error handling middleware
app.get("/api/error", () => {
throw new Error("Something went wrong");
});
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message,
stack: process.env.NODE_ENV === "production" ? undefined : err.stack,
});
});
describe("Error handling", () => {
it("returns 500 with error message", async () => {
const response = await request(app).get("/api/error");
expect(response.status).toBe(500);
expect(response.body.error).toBe("Something went wrong");
});
});
Testing with cookies and sessions
app.post("/api/login", (req, res) => {
res.cookie("session", "abc-123", { httpOnly: true });
res.json({ authenticated: true });
});
app.get("/api/dashboard", (req, res) => {
if (!req.headers.cookie?.includes("session=abc-123")) {
return res.status(401).json({ error: "No session" });
}
res.json({ data: "secret dashboard data" });
});
describe("Cookie-based sessions", () => {
it("maintains cookies across requests", async () => {
const agent = request.agent(app);
await agent.post("/api/login").expect(200);
const response = await agent.get("/api/dashboard");
expect(response.status).toBe(200);
expect(response.body.data).toBe("secret dashboard data");
});
});
Variants
Using supertest with Fastify
import Fastify from "fastify";
const fastify = Fastify();
fastify.get("/api/health", async () => ({ status: "healthy" }));
await fastify.ready();
const response = await request(fastify.server).get("/api/health");
Testing file uploads
import fs from "fs";
import path from "path";
app.post("/api/upload", (req, res) => {
res.json({ received: true });
});
it("uploads a file", async () => {
const filePath = path.join(__dirname, "fixtures", "test.txt");
fs.writeFileSync(filePath, "test content");
const response = await request(app)
.post("/api/upload")
.attach("file", filePath);
expect(response.status).toBe(200);
expect(response.body.received).toBe(true);
});
Best Practices
-
For a deeper guide, see Java Testcontainers Integration Tests.
-
Export the Express
appobject from your server file — import it in tests without starting the server -
Use
request.agent(app)to persist cookies across multiple requests in a test -
Test both success and error paths — 400, 401, 403, 404, 409, 500
-
Set
Content-Typeexplicitly when sending JSON bodies -
Use
.expect(status)as a shorthand instead of manual assertions -
Reset database state between tests — use a transaction or truncate in
beforeEach
Common Mistakes
- Starting a real server:
app.listen()in the test file causes port conflicts. Import theappobject directly. - Not setting
express.json()middleware: POST bodies arrive as empty objects without the JSON parser. - Forgetting to
awaitthe request: supertest returns a promise. Withoutawait, the test ends before the response arrives. - Testing with a real database: use mocks or a test database. Production data causes flaky tests.
- Not testing error responses: only testing the happy path misses validation and error handling bugs.
FAQ
How do I test WebSocket endpoints with supertest?
Supertest only supports HTTP. For WebSocket testing, use ws with a test client or socket.io-client in your tests.
Can I use supertest with TypeScript?
Yes. Install @types/supertest for type definitions. The API is identical.
How do I test rate limiting?
Make multiple rapid requests and check when the API returns 429:
it("returns 429 after rate limit", async () => {
for (let i = 0; i < 100; i++) {
const response = await request(app).get("/api/limited");
if (i >= 50) expect(response.status).toBe(429);
}
});
Should I mock the database in supertest tests?
It depends. For unit-level API tests, mock the data layer. For integration tests, use a real test database (SQLite in-memory, Testcontainers, or a dedicated test schema).
How do I test CORS headers?
const response = await request(app)
.options("/api/data")
.set("Origin", "https://example.com");
expect(response.headers["access-control-allow-origin"]).toBe("https://example.com"); Related Resources
Vitest Snapshot Testing for React
How to use Vitest snapshot testing to catch unintended UI changes in React components, including inline snapshots and snapshot updating workflows.
RecipeMock Network Requests with MSW
How to use Mock Service Worker (MSW) to intercept network requests in JavaScript tests and development, including REST and GraphQL mocking.
RecipeWrite Integration Tests
How to test multiple components working together using real databases, HTTP clients, and message queues in Python, JavaScript, and Java.