API Testing with Supertest and Jest
Learn how to write fast, reliable API tests for Express and Node.js backends using Supertest and Jest.
What you'll learn
- ✓How Supertest works and why it avoids the need for a running server
- ✓Testing GET, POST, PUT, and DELETE endpoints
- ✓Handling authentication, error responses, and validation
- ✓Structuring API test suites for maintainability
Prerequisites
- •Basic Express.js knowledge
- •Familiarity with Jest or similar test runners
What Supertest Does
Supertest is a library for testing HTTP APIs in Node.js. It takes your Express (or Koa, Fastify, or any http.Server) application and makes HTTP requests against it in-process — no need to start the server on a port. This makes tests fast, deterministic, and isolated.
npm install --save-dev supertest jest @types/jest @types/supertest ts-jest
Setting Up: Separating App from Server
The key to testable Express apps is separating the app configuration from the server startup:
// src/app.ts
import express from "express";
import { userRouter } from "./routes/users";
import { orderRouter } from "./routes/orders";
const app = express();
app.use(express.json());
app.use("/api/users", userRouter);
app.use("/api/orders", orderRouter);
export { app };
// src/server.ts
import { app } from "./app";
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Your tests import app, not server. This way, Supertest can make requests without binding to a port.
Your First API Test
// tests/users.test.ts
import request from "supertest";
import { app } from "../src/app";
describe("GET /api/users", () => {
it("returns a list of users", async () => {
const response = await request(app)
.get("/api/users")
.expect("Content-Type", /json/)
.expect(200);
expect(response.body).toBeInstanceOf(Array);
expect(response.body.length).toBeGreaterThan(0);
expect(response.body[0]).toHaveProperty("id");
expect(response.body[0]).toHaveProperty("name");
expect(response.body[0]).toHaveProperty("email");
});
});
Supertest chains fluently. .expect(200) asserts the status code. .expect("Content-Type", /json/) asserts the header. After the request completes, you use Jest assertions on the response body.
Testing POST Endpoints
describe("POST /api/users", () => {
it("creates a new user and returns 201", async () => {
const newUser = {
name: "Alice",
email: "alice@example.com",
};
const response = await request(app)
.post("/api/users")
.send(newUser)
.expect("Content-Type", /json/)
.expect(201);
expect(response.body).toMatchObject({
name: "Alice",
email: "alice@example.com",
});
expect(response.body.id).toBeDefined();
});
it("returns 400 for missing required fields", async () => {
const response = await request(app)
.post("/api/users")
.send({ name: "Alice" }) // missing email
.expect(400);
expect(response.body.errors).toContain("email is required");
});
it("returns 409 for duplicate email", async () => {
const user = { name: "Bob", email: "bob@example.com" };
// Create the user first
await request(app).post("/api/users").send(user).expect(201);
// Try to create again
const response = await request(app)
.post("/api/users")
.send(user)
.expect(409);
expect(response.body.error).toContain("already exists");
});
});
Testing PUT and DELETE
describe("PUT /api/users/:id", () => {
it("updates an existing user", async () => {
// Create a user first
const { body: created } = await request(app)
.post("/api/users")
.send({ name: "Alice", email: "alice@example.com" })
.expect(201);
// Update the user
const response = await request(app)
.put(`/api/users/${created.id}`)
.send({ name: "Alice Updated" })
.expect(200);
expect(response.body.name).toBe("Alice Updated");
expect(response.body.email).toBe("alice@example.com"); // unchanged
});
it("returns 404 for non-existent user", async () => {
await request(app)
.put("/api/users/99999")
.send({ name: "Ghost" })
.expect(404);
});
});
describe("DELETE /api/users/:id", () => {
it("deletes an existing user", async () => {
const { body: created } = await request(app)
.post("/api/users")
.send({ name: "ToDelete", email: "delete@example.com" })
.expect(201);
await request(app).delete(`/api/users/${created.id}`).expect(204);
// Verify the user is gone
await request(app).get(`/api/users/${created.id}`).expect(404);
});
});
Testing Authentication
Most APIs require authentication. Test both authenticated and unauthenticated scenarios:
describe("Protected routes", () => {
let authToken: string;
beforeAll(async () => {
// Register and login to get a token
await request(app)
.post("/api/auth/register")
.send({ email: "test@example.com", password: "Password1!" });
const loginResponse = await request(app)
.post("/api/auth/login")
.send({ email: "test@example.com", password: "Password1!" });
authToken = loginResponse.body.token;
});
it("returns 401 without a token", async () => {
await request(app).get("/api/profile").expect(401);
});
it("returns 401 with an invalid token", async () => {
await request(app)
.get("/api/profile")
.set("Authorization", "Bearer invalid-token")
.expect(401);
});
it("returns profile with a valid token", async () => {
const response = await request(app)
.get("/api/profile")
.set("Authorization", `Bearer ${authToken}`)
.expect(200);
expect(response.body.email).toBe("test@example.com");
});
});
The .set() method adds headers to the request. Use it for Authorization, Content-Type, custom headers, and API keys.
Testing Query Parameters and Pagination
describe("GET /api/users with query params", () => {
it("supports pagination", async () => {
const response = await request(app)
.get("/api/users")
.query({ page: 2, limit: 10 })
.expect(200);
expect(response.body.data.length).toBeLessThanOrEqual(10);
expect(response.body.meta.page).toBe(2);
expect(response.body.meta.totalPages).toBeDefined();
});
it("supports filtering by role", async () => {
const response = await request(app)
.get("/api/users")
.query({ role: "admin" })
.expect(200);
response.body.data.forEach((user: any) => {
expect(user.role).toBe("admin");
});
});
it("supports sorting", async () => {
const response = await request(app)
.get("/api/users")
.query({ sort: "name", order: "asc" })
.expect(200);
const names = response.body.data.map((u: any) => u.name);
const sorted = [...names].sort();
expect(names).toEqual(sorted);
});
});
Database Setup and Teardown
API tests often need a database. Use Jest lifecycle hooks to manage state:
import { db } from "../src/database";
beforeAll(async () => {
// Run migrations or create tables
await db.migrate.latest();
});
beforeEach(async () => {
// Seed test data
await db("users").insert([
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
]);
});
afterEach(async () => {
// Clean up after each test
await db("users").truncate();
await db("orders").truncate();
});
afterAll(async () => {
// Close the connection pool
await db.destroy();
});
For faster tests, wrap each test in a database transaction and roll it back:
beforeEach(async () => {
await db.raw("BEGIN");
});
afterEach(async () => {
await db.raw("ROLLBACK");
});
Testing File Uploads
Supertest handles multipart form data:
describe("POST /api/upload", () => {
it("uploads a file successfully", async () => {
const response = await request(app)
.post("/api/upload")
.attach("file", Buffer.from("file content"), "test.txt")
.expect(200);
expect(response.body.filename).toBe("test.txt");
expect(response.body.size).toBeGreaterThan(0);
});
it("rejects files that are too large", async () => {
const largeBuffer = Buffer.alloc(10 * 1024 * 1024); // 10 MB
await request(app)
.post("/api/upload")
.attach("file", largeBuffer, "big-file.txt")
.expect(413);
});
});
Helper Functions for Cleaner Tests
As your test suite grows, extract helpers to reduce duplication:
// tests/helpers.ts
import request from "supertest";
import { app } from "../src/app";
export async function createUser(data: { name: string; email: string }) {
const response = await request(app)
.post("/api/users")
.send(data)
.expect(201);
return response.body;
}
export async function authenticatedRequest(token: string) {
return {
get: (url: string) =>
request(app).get(url).set("Authorization", `Bearer ${token}`),
post: (url: string) =>
request(app).post(url).set("Authorization", `Bearer ${token}`),
put: (url: string) =>
request(app).put(url).set("Authorization", `Bearer ${token}`),
delete: (url: string) =>
request(app).delete(url).set("Authorization", `Bearer ${token}`),
};
}
Usage:
import { createUser, authenticatedRequest } from "./helpers";
it("fetches own profile", async () => {
const user = await createUser({ name: "Alice", email: "a@b.com" });
const client = await authenticatedRequest(user.token);
const response = await client.get("/api/profile").expect(200);
expect(response.body.name).toBe("Alice");
});
Organizing Test Files
Structure your test files to mirror your route structure:
tests/
api/
users.test.ts
orders.test.ts
auth.test.ts
upload.test.ts
helpers.ts
setup.ts # global beforeAll/afterAll
Configure Jest to use the setup file:
// jest.config.js
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
setupFilesAfterSetup: ["./tests/setup.ts"],
testMatch: ["**/tests/**/*.test.ts"],
};
Key Takeaways
Supertest lets you test Express APIs without starting a server, making tests fast and reliable. Separate your app configuration from server startup so Supertest can import the app directly. Test the full range of scenarios: happy paths, validation errors, authentication, authorization, edge cases, and error responses. Use database transactions for test isolation, extract helper functions to reduce duplication, and organize test files to mirror your route structure. The result is an API test suite that runs in seconds and catches regressions before they reach production.
Related articles
- Testing Jest vs Vitest: JavaScript Test Runners Compared
Compare Jest and Vitest for JavaScript testing. Understand speed, configuration, compatibility, and migration tradeoffs to pick the right test runner.
- Testing Snapshot Testing: Patterns and Anti-Patterns
Learn when snapshot tests help, when they hurt, and how to use them effectively in React, API, and configuration testing.
- Testing Snapshot Tests Explained
Learn how snapshot tests work, when they shine, and how to keep them maintainable instead of letting them rot into noise.
- React React Testing Library: Complete Testing Guide
Learn to test React components with React Testing Library. Covers rendering, user events, async testing, mocking, and accessibility-driven queries.