API Testing

Supertest

Supertest drives your Node HTTP app in-process and turns API tests into plain assertions on status, headers and body — no server to start, no port to manage, no network to mock.

beginner14 min readUpdated Sep 16, 2026
posts.test.ts
ts
// posts.test.ts
import request from "supertest";
import { expect, test } from "vitest";
import app from "../src/app.js";

test("GET /posts returns a list", async () => {
  const res = await request(app)
    .get("/posts")
    .expect("Content-Type", /json/)
    .expect(200);

  expect(res.body).toEqual(
    expect.arrayContaining([expect.objectContaining({ id: 1 })]),
  );
});
Built on
superagent
Runs against
Your app object
Test runners
Vitest, Jest, node:test
Network
In-process, ephemeral port
Style
Chainable assertions
First release
2011

Why it matters

Why Supertest earns its place

Your app, no server

Point Supertest at the exported app function and it starts an ephemeral server for the test, then closes it. There is no port to pick and no process to babysit.

Chainable requests

The superagent-style chain — method, set, send, query — reads like the HTTP request you are describing, so tests double as documentation.

Assertions in the chain

expect(status), expect(header, value) and expect(body) fail with the real response attached, which shortens the loop from red test to cause.

The big picture

Three ideas that make it click

You hand Supertest an app, it builds a request for you, and the response is yours to assert on like any other object.

The app object

Import

Export the request listener from app.ts and keep listen() in server.ts. Tests import the app and never open a fixed port.

The request

Compose

get, post, set, send and query build an HTTP call. Objects are serialised to JSON and the matching Content-Type is set for you.

The response

Assert

status, headers, body and text are plain values, so you assert on them with the same expect you use everywhere else.

At a glance

The Supertest toolbox

request(app)

Hand the app to Supertest and get a chainable request builder back.

Methods

.get, .post, .put, .patch and .delete map straight to HTTP verbs.

Bodies

.send({...}) serialises to JSON and sets Content-Type for you.

Auth

.set('Authorization', ...) or .auth() attach credentials to the request.

Response

res.status, res.headers and res.body are ready for assertions.

Uploads

.attach() and .field() build multipart form submissions.

Flow

How one Supertest test flows

Every Supertest test walks the same path, from importing the app to cleaning up the data it touched.

  1. 1

    Import the app

    Import the exported request listener, not a running server. It is the same object your production entry point uses.

  2. 2

    Build the request

    Call request(app) and chain the method, path, headers, query and body you want to exercise.

  3. 3

    Send and await

    Await the chain. Supertest starts an ephemeral server, dispatches the request and resolves with the response.

  4. 4

    Assert status and headers

    Check the status code and Content-Type first; they catch routing and serialisation mistakes before body assertions do.

  5. 5

    Assert the body

    Compare the parsed body with the shape the contract promises, using your test runner's expect for anything complex.

  6. 6

    Clean up

    Reset the data you touched and close the database or connection pool so the next test starts from a known state.

The complete guide

Supertest: Everything you need to know

What is Supertest?

Supertest is an HTTP assertion library for Node.js. It takes an app object — an Express app, a Fastify instance, a bare http request listener — and lets you make requests against it with a chainable API, then assert on the response.

It is built on superagent, so the request side will feel familiar if you have used that library. What Supertest adds is the testing ergonomics: an app can be passed directly instead of a URL, the server lifecycle is handled for you, and .expect() assertions can be attached to the chain.

The important thing to understand is that Supertest does not run a browser and does not start your production server. It creates a short-lived HTTP server in the same process, bound to an ephemeral port on localhost, dispatches the request, and shuts it down when the response resolves. Your test sees the real HTTP stack — status codes, headers, bodies, serialisation — without the cost and flakiness of a separate process.

Why test in-process

Most of the pain in HTTP testing comes from the server, not the request. A separate process needs a port, a startup wait, a health check and a teardown. Fixed ports collide in CI, and a race between listen and the first request produces failures that look like application bugs.

Testing in-process removes all of that. There is no process to start, so there is no readiness check. There is no fixed port, so parallel test files do not fight each other. There is no network boundary, so a failure points at your handler rather than at the environment.

You still get the full request pipeline: middleware, routing, body parsing, authentication and error handling all run exactly as they do in production, because they are the same code. What you give up is the things that only a real browser or a real network can provide — JavaScript execution, a rendering engine, and the exact behaviour of a proxy in front of your app.

Export the app, not the listener

For Supertest to import your app, the app has to be exportable. The common mistake is to define routes and call listen in the same module, which starts a server the moment the file is imported and leaves you with a port in your tests.

Split the two concerns:

// src/app.ts
import express from "express";
import { postsRouter } from "./routes/posts.js";

export const app = express();

app.use(express.json());
app.use("/posts", postsRouter);

app.use((err, req, res, next) => {
  res.status(err.status ?? 500).json({ error: err.code ?? "internal_error" });
});
// src/server.ts
import { app } from "./app.js";

app.listen(3000, () => console.log("listening on http://localhost:3000"));

Now src/app.ts exports the request listener and nothing else. Tests import it, and production imports it from server.ts. This one split is the difference between an API you can test in milliseconds and one you have to boot.

Express’s app is itself a function with the (req, res) signature, which is exactly what Node’s http.createServer expects. That is why request(app) works without any adapter. Fastify needs app.server or an awaited app.ready(), and a bare Node handler works directly.

Making a request

A request begins with request(app) and the HTTP method. Every method that superagent supports is available, and the chain returns the same request object so calls can be stacked.

import request from "supertest";
import app from "../src/app.js";

await request(app).get("/posts");
await request(app).post("/posts").send({ title: "Hello" });
await request(app).patch("/posts/1").send({ title: "Updated" });
await request(app).delete("/posts/1");

send serialises an object to JSON and sets the Content-Type header automatically. A string is sent as-is, which is useful when you are testing a malformed body on purpose.

Headers are set with set, either one at a time or as an object. Query parameters are cleaner through query, which encodes and appends them for you.

await request(app)
  .get("/posts")
  .query({ page: 2, perPage: 10 })
  .set("Accept", "application/json")
  .set({ "X-Request-Id": "test-1" });

The resulting URL is /posts?page=2&perPage=10. If you need a raw body — XML, a plain string, a deliberately broken payload — pass set("Content-Type", ...) and send the string.

Asserting on the response

The response is a normal object with status, headers, body and text. You can assert on it with your test runner’s expect, or you can use Supertest’s .expect() directly in the chain.

const res = await request(app).get("/posts").expect(200);

expect(res.headers["content-type"]).toMatch(/application\/json/);
expect(res.body).toHaveLength(3);

Supertest’s .expect() is convenient because it fails with the full response in the error message, which is usually enough to see what went wrong without adding a log line.

await request(app)
  .get("/posts")
  .expect("Content-Type", /json/)
  .expect(200);

.expect() accepts a status code, a header name and value, a body for deep equality, or a function that receives the response and can throw. The function form is the escape hatch when an assertion needs logic.

await request(app)
  .get("/posts")
  .expect((res) => {
    if (!res.body.every((p: { id: number }) => p.id > 0)) {
      throw new Error("every post must have a positive id");
    }
  });

Prefer the runner’s expect for anything beyond the status line and the content type. It gives better diffs, supports matchers like toMatchObject and arrayContaining, and keeps the assertion style consistent with the rest of your suite.

Mixing Supertest with your test runner

Supertest is not a test runner. It builds requests and can assert on responses, but it does not discover tests, provide describe and it, mock modules or report results. That job belongs to Vitest, Jest or node:test.

The two compose cleanly because a Supertest request is thenable. Awaiting it resolves the response, so a test is just an async function.

import request from "supertest";
import { beforeEach, describe, expect, it } from "vitest";
import app from "../src/app.js";

describe("POST /posts", () => {
  beforeEach(async () => {
    await resetDatabase();
  });

  it("creates a post", async () => {
    const res = await request(app)
      .post("/posts")
      .send({ title: "Hello" })
      .expect(201);

    expect(res.body.title).toBe("Hello");
  });
});

If you forget to await, the test passes before the request is even dispatched and the failure surfaces later as an unhandled rejection. await every request, even the ones whose only assertion is .expect().

Testing authentication

Authentication is just a header or a cookie, so both are easy to exercise.

For bearer tokens, set the Authorization header. Sign a token with the same test secret the app uses rather than calling the real identity provider.

const token = await signTestToken({ sub: "user_1", scope: "read:posts" });

await request(app).get("/posts").expect(401);

await request(app)
  .get("/posts")
  .set("Authorization", `Bearer ${token}`)
  .expect(200);

For session cookies, request.agent(app) keeps a cookie jar across requests, which mimics a browser following a login.

const agent = request.agent(app);

await agent
  .post("/login")
  .send({ email: "[email protected]", password: "secret" })
  .expect(204);

await agent.get("/me").expect(200);

When you already have a cookie value, set it directly. Cookies are passed as an array or a semicolon-joined string.

await request(app).get("/me").set("Cookie", ["session=abc123"]).expect(200);

Test the negative cases as carefully as the happy path: no token, an expired token, a token for another audience, and a valid token without the required scope. Those four tests protect you far more than one success case.

Setting up and tearing down data

Supertest has no opinion about the database, which means a shared database will leak state between tests unless you reset it. There are three common strategies.

Reset before each test. Truncate the tables the suite touches in beforeEach. It is simple and predictable, and the cost is acceptable for small suites.

beforeEach(async () => {
  await db.query("TRUNCATE posts RESTART IDENTITY CASCADE");
  await db.query("INSERT INTO posts (id, title) VALUES (1, 'Seeded')");
});

One transaction per test. If your app and your test share the same connection, wrap each test in a transaction and roll it back in afterEach. This is fast and leaves the database untouched, but it only works when the app uses the same client, which is not always true over HTTP.

A fresh database per test file. Boot an in-memory or containerised database for the file, migrate it, and discard it at the end. This gives the strongest isolation and is the approach most integration suites settle on, at the cost of a slower first test.

Whichever you choose, close the connection pool in afterAll. An open pool keeps the Node process alive and turns a passing suite into a hanging CI job.

Testing the unhappy paths

A route is not tested until its failures are tested. The status code is part of the contract, so assert it explicitly.

await request(app).get("/posts/999").expect(404);
await request(app).post("/posts").send({}).expect(422);
await request(app).get("/admin").expect(403);

A validation failure should tell you which field failed, not just that something did.

const res = await request(app)
  .post("/posts")
  .send({ title: "" })
  .expect(422);

expect(res.body).toEqual({
  error: "validation_error",
  fields: { title: "required" },
});

The distinctions matter. 400 is a malformed request, 401 means unauthenticated, 403 means authenticated but not allowed, 404 means the resource does not exist, and 422 means the body parsed but failed validation. Asserting the wrong one is a bug in the test that hides a bug in the app.

File uploads and multipart

Supertest builds multipart requests with attach for files and field for the accompanying form fields. Pass a buffer or a path; when you pass a buffer, give it a filename so the server sees a sensible one.

await request(app)
  .post("/users/1/avatar")
  .field("caption", "Profile picture")
  .attach("avatar", Buffer.from("fake-image"), "avatar.png")
  .expect(201);

Test the rejections too: a missing file, a file over the size limit, and a disallowed MIME type. Uploads are one of the most common places for a validation gap to hide.

Testing pagination, filtering and sorting

Query strings are part of the API contract, so they deserve tests. query makes them readable, and asserting on the length and ordering of the body catches off-by-one and default-value bugs.

test("GET /posts paginates", async () => {
  await seedPosts(25);

  const page1 = await request(app)
    .get("/posts")
    .query({ page: 1, perPage: 10 })
    .expect(200);

  expect(page1.body).toHaveLength(10);
  expect(page1.body[0].id).toBe(1);

  const page3 = await request(app)
    .get("/posts")
    .query({ page: 3, perPage: 10 })
    .expect(200);

  expect(page3.body).toHaveLength(5);
});

Test the boundaries, not just the middle: the first page, the last page, a page past the end, and an invalid perPage that should be clamped or rejected.

await request(app)
  .get("/posts")
  .query({ page: 999 })
  .expect(200)
  .expect((res) => {
    if (res.body.length !== 0) throw new Error("expected an empty page");
  });

await request(app).get("/posts").query({ perPage: 10_000 }).expect(400);

Filtering and sorting are just as testable, and they are where a missing index or a wrong ORDER BY shows up as a subtle bug.

const res = await request(app)
  .get("/posts")
  .query({ status: "published", sort: "-createdAt" })
  .expect(200);

expect(
  res.body.every((p: { status: string }) => p.status === "published"),
).toBe(true);

Redirects, cookies and other HTTP details

Not every response is a JSON body. Status codes such as 301, 302 and 304, and headers such as Location, Set-Cookie and Cache-Control, are often the whole behaviour under test.

By default supertest does not follow redirects, which is what you want when you are asserting on the redirect itself.

const res = await request(app).get("/old-posts").expect(301);

expect(res.headers.location).toBe("/posts");

If the redirect chain is what matters, redirects(1) follows one hop and resolves with the final response.

await request(app).get("/old-posts").redirects(1).expect(200);

Cookies are visible in set-cookie, and the agent’s jar lets you assert that a login set the right attributes without decoding the value.

const res = await request.agent(app)
  .post("/login")
  .send({ email: "[email protected]", password: "secret" })
  .expect(204);

const cookie = res.headers["set-cookie"][0];
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("SameSite=Lax");

Conditional requests, compression and caching headers are all worth a test when you rely on them, because a proxy or CDN will happily change the behaviour if the headers are wrong.

Speeding up the suite

A Supertest suite is usually fast, but a few habits keep it that way as it grows.

Reuse expensive setup in beforeAll and reset only the mutable parts per test. Booting a container or migrating a schema once per file rather than once per test can cut minutes from a large suite.

Run test files in parallel. Vitest and Jest both do this by default, and because each Supertest request uses an ephemeral port there are no collisions to resolve. The one thing you must guarantee is that files do not share database rows.

Skip unrelated work in tests that only read. If a route only needs a user and a post, do not seed the whole fixture set. Smaller fixtures are faster to create and easier to reason about.

# run one file while iterating
pnpm exec vitest run test/posts.test.ts

# watch the file you are editing
pnpm exec vitest test/posts.test.ts

Finally, keep unit tests for the pure logic and let the HTTP tests cover the integration. A suite that pushes every calculation through a full request is slow for no extra confidence.

Organising a test suite

Mirror the source layout so a failing test points at a file you can find. If the app has src/routes/posts.ts, put test/posts.test.ts next to it in the test tree.

Keep shared setup in a small number of helpers:

  • A test/app.ts that builds the app with test configuration.
  • A test/db.ts that migrates, truncates and closes the database.
  • A test/factories.ts with functions that create users, posts and tokens.
  • A test/tokens.ts that signs a token with the test secret.
// test/factories.ts
export async function createUser(overrides: Partial<User> = {}) {
  return db.user.create({
    data: { email: "[email protected]", role: "member", ...overrides },
  });
}

Factories keep tests readable because the interesting value is the override. A test that says createUser({ role: "admin" }) communicates its intent in a way that a wall of literal fields does not.

Keeping tests independent

Every test should pass on its own and in any order. That property is what lets a runner parallelise files and what stops a single failure from cascading into a dozen misleading ones.

The enemies of independence are shared mutable state: a module-level counter, a seeded row that another test deletes, a mocked clock that is never restored, a database that is only set up once. Reset the pieces each test depends on, and never rely on a previous test to have created something.

Where a fixture is genuinely expensive — a migrated database, a running container — create it once in beforeAll and reset the mutable parts in beforeEach. The distinction is between setup that is read-only and setup that changes.

Best practices

  • Export the app from app.ts and keep listen() in server.ts.
  • Await every request; a missing await is a silent pass.
  • Assert the status code and content type before the body.
  • Test the negative paths — 400, 401, 403, 404, 422 — not just the happy one.
  • Sign test tokens with a test secret instead of calling the real provider.
  • Reset the data each test touches and close the pool in afterAll.
  • Keep one behaviour per test so a failure names the thing that broke.
  • Prefer the runner’s expect for body assertions and .expect() for the status line.
  • Run the suite against the same middleware stack production uses.

Common mistakes

  • Calling listen() in the imported module, so every test file opens a port.
  • Forgetting await, which makes a test pass before the request is sent.
  • Sharing a database row between tests and depending on execution order.
  • Testing the framework — asserting that Express parses JSON — instead of your code.
  • Asserting a 200 when the route should return a 404.
  • Leaving the database pool open so the process never exits.
  • Mocking the database so heavily that the test only proves the mock works.
  • Checking the body with a string match when a structural matcher would be clearer.
  • Ignoring headers such as Location, Set-Cookie and cache directives.

Where to go next

Supertest covers the HTTP boundary, and it pairs with everything around it. Read Vitest to learn the runner that will execute these tests, or Jest if your project already uses Jest. The Express guide explains the app object you are handing to Supertest, and REST covers the status codes and semantics your assertions encode. Once the API is covered, End-to-End Testing shows how to prove the same journeys through a real browser.

In practice

From first request to real data

Four tests that cover the shape of a typical API suite.

posts.test.ts
import request from "supertest";
import { expect, test } from "vitest";
import app from "../src/app.js";

test("GET /posts returns a list", async () => {
  const res = await request(app).get("/posts").expect(200);

  expect(res.headers["content-type"]).toMatch(/json/);
  expect(res.body).toHaveLength(3);
});

Import the app vs start a server

Supertest can test a running server by URL, but importing the app keeps the test in one process and removes the port and timing problems.

Prefer
import app from "../src/app.js";
import request from "supertest";

const res = await request(app).get("/health").expect(200);
Avoid
const server = app.listen(3000);

const res = await fetch("http://localhost:3000/health");
expect(res.status).toBe(200);

server.close();
// a fixed port collides in CI and the server
// may not be ready when fetch runs

Assert the contract vs assert internals

Test the response a client actually sees. Reaching into the database or private helpers couples the suite to implementation details that are free to change.

Prefer
const res = await request(app)
  .post("/posts")
  .send({ title: "Hello" })
  .expect(201);

expect(res.body).toMatchObject({ title: "Hello" });
Avoid
await request(app)
  .post("/posts")
  .send({ title: "Hello" })
  .expect(201);

const [row] = await db.query("SELECT * FROM posts");
expect(row.title).toBe("Hello");
// breaks the moment the schema or query changes

Trade-offs

Where Supertest stops

Supertest is a request builder and assertion helper, not a testing strategy. Know what it deliberately leaves to you.

Strengths

  • Almost nothing to set up

    If you already have an app object and a test runner, one import is the whole installation.

  • Fast and hermetic

    Tests run in the same process with an ephemeral port, so there is no external service to start and no network flakiness.

  • Reads like the request

    The chain mirrors HTTP, which makes failures easy to read and new tests quick to write.

Trade-offs

  • It does not manage state

    Supertest has no fixtures or transactions. Keeping the database clean between tests is entirely your responsibility.

  • It cannot catch rendering bugs

    Everything below the HTTP boundary is invisible. A 200 response says nothing about whether the UI can use it.

  • It is not a browser

    No JavaScript executes, cookies are not enforced like a browser, and redirects and CORS behave differently.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning Supertest?

Our interactive tutorial walks you through Supertest step by step — with quizzes and real code you can run in the browser.