mrkeyoor.com_
Thu 06 Aug 08:50 UTC
npmTestingupdated 06 Aug 2026

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.

Verdict

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.

API stability5/5The request(app).get(path).expect(200) chain reads the same today as it did a decade ago; 7.x releases have been superagent 10 compatibility, Node version floors, and bug fixes rather than API changes.
Docs3/5The README covers the common flows well (mocha, promises, async, cookies, uploads) but the actual API section is five .expect() overloads plus a pointer to superagent's site; undocumented additions like .bearer(token) exist only in the source, and there is no changelog beyond release notes.
Maintenance3/57.2.2 shipped January 2026 and the repo was pushed in April 2026, but 168 open issues (189 counting PRs) sit against it and the repo moved from the ladjs org to forwardemail, so most reports get triaged slowly; releases are fixes and dependency bumps rather than direction.
Ecosystem5/5About 17.2 million weekly downloads and 14.4k stars; NestJS scaffolds its e2e test file with supertest, and most Express testing tutorials assume it, so answers to your problem already exist.

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
Skip it if

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 cookie

request.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

PackageRegistryPick it when
superagentnpmYou want the same request builder outside of tests, hitting a server that is already running, with your own assertion library on top.
light-my-requestnpmYou 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.
undicinpmYou 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-httpnpmYour suite is already built on chai and you want HTTP assertions expressed in the same expect/should chain as the rest of your tests.