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.tsthat builds the app with test configuration. - A
test/db.tsthat migrates, truncates and closes the database. - A
test/factories.tswith functions that create users, posts and tokens. - A
test/tokens.tsthat 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.tsand keeplisten()inserver.ts. - Await every request; a missing
awaitis 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
expectfor 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-Cookieand 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.