Skip to content
Codeloom
Testing

API Testing with Supertest and Jest

Learn how to write fast, reliable API tests for Express and Node.js backends using Supertest and Jest.

·7 min read · By Codeloom
Intermediate 10 min read

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.