supertest
supertest lets you fire real HTTP requests at an Express, Koa, Fastify, or plain Node http app from inside a test, without starting a server or choosing a port yourself. You pass your app to request(); if it is a bare handler function supertest wraps it in http.createServer, and if it is not already listening it calls app.listen(0) to grab a free port, sends the request to 127.0.0.1, then closes that listener once the request finishes. On top of superagent's request builder it adds .expect(), which asserts status codes, header values, and response bodies inline and turns any mismatch into a test failure. It works under every runner (Jest, Mocha, Vitest, node:test) because a Test object is thenable, so you can just await it.
For an Express or Koa API this is still the shortest path from 'I have routes' to 'I have route tests', and the ecosystem assumes it. Go in knowing that you inherit superagent's API and its quirks, and that .end() callbacks hide failed assertions unless you forward the error yourself.
Use it if
- You want route tests that go through your real router, middleware chain, and body parsers instead of calling handlers with hand-faked req and res objects
- You do not want to manage ports, listen calls, or server teardown in test files: supertest binds an ephemeral port per request and closes it in .end()
- You need multi-request flows that depend on session cookies, because request.agent(app) stores and replays Set-Cookie the way a browser does
- You already know superagent's chain (.send, .field, .attach, .query, .auth) and want the same builder plus assertions inside tests
- Your server is not a Node http.Server or a handler function: a Deno or Bun native server, or a service you can only reach across the network, needs plain fetch or a browser-level tool instead
- Your framework already ships an injection client: Fastify's app.inject() and the light-my-request engine behind it skip the socket entirely, so they are faster and cannot leak a listener
- You write TypeScript and dislike DefinitelyTyped: supertest ships no .d.ts at all, so you add @types/supertest as a second devDependency and it trails supertest releases
- You use .end(callback) instead of await: a failed .expect() inside .end() is handed to the callback as an error rather than thrown, so a test that forgets 'if (err) return done(err)' passes with a broken assertion
- You want a documented, fast-moving project: the API section of the README is a handful of .expect() overloads and everything else points at superagent's docs, and the repo carries 168 open issues (189 counting PRs)
Setup reality
npm i -D supertest, and that is genuinely it: three small runtime deps (superagent, methods, cookie-signature), no peer deps, no native builds, engines set at Node 14.18 or newer. The friction shows up later. TypeScript users need @types/supertest separately, because the package ships no types of its own. The package is CommonJS with a plain main entry and no exports map, so in ESM test files you get default interop only: import request from 'supertest' works, named imports do not. Your app module has to export the app without ever calling app.listen(), or parallel test files fight over one port. supertest disables redirect following by default (it calls redirects(0) in the Test constructor), which surprises anyone testing a 302. And it only closes the listener it created, so if your app opens a database pool or a Redis client, Jest keeps warning about handles that failed to close until you shut those down in afterAll.
Patterns
Assert status, content type, and body in one chainassert-status-and-json
const request = require('supertest');
const app = require('../app');
it('returns the user as JSON', async () => {
await request(app)
.get('/user')
.expect('Content-Type', /json/)
.expect(200, { name: 'john' });
});Assertions run in the order you declare them, and the first failure wins. Passing an object to .expect(status, body) does a deep equality check on the parsed body, not a subset match.
Await the response and assert with your own matchersawait-and-inspect
const res = await request(app)
.get('/users')
.set('Accept', 'application/json');
expect(res.status).toBe(200);
expect(res.body).toEqual([{ id: 1 }]);A Test is thenable, so awaiting it performs the request. Without any .expect(status) call, superagent rejects on 4xx and 5xx, so wrap in try/catch if you are deliberately testing an error response.
POST a JSON bodypost-json-body
await request(app)
.post('/users')
.send({ name: 'john' })
.set('Accept', 'application/json')
.expect(201);.send(object) sets Content-Type to application/json for you. Pass a string like 'name=john' instead and it goes out as x-www-form-urlencoded, which is a common source of 400s from body parsers.
Send basic auth or a bearer tokenauth-headers
await request(app).get('/admin').auth('user', 'pass').expect(200);
await request(app).get('/me').bearer(jwt).expect(200);
// same as: .set('Authorization', `Bearer ${jwt}`).auth() comes from superagent. .bearer() is supertest's own shortcut and is not in the README, so older @types/supertest versions may not declare it; .set('Authorization', ...) always works.
Keep a session across several requestspersist-cookies
const agent = request.agent(app);
await agent.post('/login').send({ user: 'ann', pass: 'x' }).expect(200);
await agent.get('/dashboard').expect(200); // sends the session cookierequest.agent stores Set-Cookie from each response and replays it on the next call. A plain request(app) call keeps nothing, which is why login-then-fetch tests fail with a 401 when you forget the agent.
Write a custom assertion over the responsecustom-assertion
function hasPagination(res) {
if (!('next' in res.body)) throw new Error('missing next key');
if (!('prev' in res.body)) throw new Error('missing prev key');
}
await request(app).get('/items').expect(hasPagination).expect(200);Throw to fail; a return value is ignored. Because assertion functions run in order and can mutate res, you can normalize volatile fields (ids, timestamps) in one .expect() and deep-compare in the next.
Upload a file with multipart form fieldsfile-upload
await request(app)
.post('/avatar')
.field('name', 'my avatar')
.field('meta', '{"a":1}', { contentType: 'application/json' })
.attach('avatar', 'test/fixtures/avatar.jpg')
.expect(201);Paths in .attach() resolve from the process working directory, not the test file, so use path.join(__dirname, ...) if you run tests from a subfolder. You can also attach a Buffer with a third filename argument.
Test a redirect, or follow itfollow-redirects
// default: redirects are NOT followed
await request(app)
.get('/old')
.expect(302)
.expect('Location', '/new');
// opt in to following
await request(app).get('/old').redirects(1).expect(200);supertest calls redirects(0) in its constructor, unlike bare superagent which follows up to five. That is why asserting 200 on a redirecting route fails until you add .redirects(n).
Use .end() without silently passing broken testscallback-error-forwarding
it('responds with json', (done) => {
request(app)
.post('/users')
.send({ name: 'john' })
.expect(200)
.end((err, res) => {
if (err) return done(err);
done();
});
});This is the single biggest supertest footgun: inside .end(), a failed .expect() arrives as err instead of throwing. Drop the 'if (err) return done(err)' line and the test passes no matter what the server returned.
Point at a server that is already runningtest-running-server
const request = require('supertest');
const api = request('http://localhost:5555');
await api.get('/health').expect(200);
await api.get('/version').expect(/1\.\d+/);Pass a base URL string instead of an app and supertest skips listen() entirely. Handy for smoke tests against staging, but you lose the automatic port and teardown, and network flakiness becomes test flakiness.
Test over HTTP/2http2-requests
await request(app, { http2: true })
.get('/user')
.expect(200);
const agent = request.agent(app, { http2: true });The option wraps your handler with http2.createServer instead of http.createServer, so it only applies when you pass a function. It throws at call time on a Node build without the http2 module.
Structure the app so tests do not leak handlesapp-export-and-teardown
// app.js
const app = express();
module.exports = app; // never app.listen() here
// server.js
require('./app').listen(process.env.PORT);
// user.test.js
afterAll(async () => { await db.end(); });supertest closes the listener it opened, but nothing else. Database pools, Redis clients, and timers keep the Jest worker alive and produce the 'failed to exit gracefully' warning until you close them yourself.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| superagent | npm | You want the same request builder outside of tests, hitting a server that is already running, with your own assertion library on top. |
| light-my-request | npm | You are on Fastify or any framework with an inject entry point and want requests dispatched in-process with no socket, no port, and nothing to close. |
| undici | npm | You test a service over real HTTP at a known URL and would rather use the Node HTTP client directly with your runner's own expect(). |
| chai-http | npm | Your suite is already built on chai and you want HTTP assertions expressed in the same expect/should chain as the rest of your tests. |