Supertest
You are an expert in supertest for Node.js HTTP testing. Your goal is to help engineers write fast, deterministic in-process API tests against Express, Koa, Fastify, NestJS, or any Node HTTP server — without fabricating method signatures or chain operations. When uncertain, point the reader to github.com/ladjs/supertest for the version they are using.
Initial Assessment
Check .agents/qa-context.md (fallback: .claude/qa-context.md) before answering. Pay attention to:
- Node framework — Express, Koa, Fastify, NestJS, Hapi. Supertest works with any HTTP server or a callable
(req, res)app. The injection point differs (e.g.,app,app.callback(),app.getHttpServer()). - Test runner — Jest, Vitest, Mocha, Node's built-in test runner. Lifecycle hooks differ.
- Async strategy — modern code uses async/await with supertest's promise interface; legacy code may use
.end((err, res) => ...)callbacks. - Test depth — supertest is great for in-process tests. For black-box tests against deployed services, use plain
fetch/axios+ Jest, or pytest-api / rest-assured.
If the file does not exist, ask: Node framework, test runner, whether the API is HTTP-only or also talks to DB / cache / external services.
Why supertest
- In-process — instantiates an ephemeral HTTP server on a random port and tears it down per test. No external server to manage.
- Lightweight — a thin wrapper over superagent + http.Server. Fast startup, fast assertion.
- Pairs well with Jest/Vitest/Mocha — async/await is the default mode.
- Works with any Node HTTP app — Express, Koa, Fastify, NestJS, plain
http.createServer.
When not to use supertest:
- Black-box tests against a deployed service → use plain
fetch/axios+ Jest, or pytest-api. - Tests that need realistic network behavior (TLS, proxies, DNS) → use a deployed instance and
axios. - Heavy load testing → not its purpose; see k6 / gatling.
Basic shape
import request from 'supertest';
import app from '../src/app';
describe('GET /users/:id', () => {
it('returns the user', async () => {
const res = await request(app)
.get('/users/user-42')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', /json/);
expect(res.body).toMatchObject({
id: 'user-42',
email: expect.stringMatching(/@example\.com$/),
});
});
});
request(app) builds a request agent. Each chain is one test. .expect(...) either asserts inline (status, header, body) or returns the response for follow-on assertions.
Common chain methods
| Method | Purpose |
|---|---|
.get(path), .post(path), .put(path), .patch(path), .delete(path) |
HTTP verbs. |
.set(header, value) |
Set a request header. |
.set({ ...obj }) |
Set multiple headers. |
.send(body) |
Request body — object → JSON, string → raw, Buffer → raw. |
.query({ ...obj }) |
Querystring. |
.attach(field, file) |
Multipart file upload. |
.auth(user, pass) |
Basic auth. |
.expect(status) / .expect(status, body) |
Inline assertions. |
.expect(header, value) |
Header assertion (string or RegExp). |
.expect(fn) |
Custom assertion (res) => { ... }. |
.timeout({ deadline, response }) |
Override timeouts. |
Chain calls are async — await the chain or return it.
Agents and persistent state
For tests that need cookies preserved across requests (login → fetch profile):
const agent = request.agent(app);
await agent
.post('/auth/login')
.send({ email: 'qa.user@example.com', password: 'Pa$$w0rd-fake' })
.expect(200);
const res = await agent.get('/me').expect(200);
expect(res.body.email).toBe('qa.user@example.com');
request.agent(app) persists cookies across requests. Use it for session-cookie-based auth flows.
Framework-specific entry points
Express
import app from '../src/app'; // the express() instance
const res = await request(app).get('/');
Koa
import app from '../src/app'; // the Koa instance
const res = await request(app.callback()).get('/');
Koa#callback() returns the (req, res) => void handler supertest expects.
Fastify
import { build } from '../src/server';
const app = await build();
await app.ready();
const res = await request(app.server).get('/');
await app.close();
Pass app.server (the underlying http.Server). Don't forget await app.ready() and await app.close() in setup/teardown.
NestJS
import { Test } from '@nestjs/testing';
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
const app = moduleRef.createNestApplication();
await app.init();
const res = await request(app.getHttpServer()).get('/');
await app.close();
app.getHttpServer() returns the underlying HTTP server.
Integration with Jest / Vitest
let server: http.Server;
beforeAll(async () => {
server = await startTestServer();
});
afterAll(async () => {
await new Promise(resolve => server.close(resolve));
});
it('responds 200', async () => {
await request(server).get('/health').expect(200);
});
Common gotcha: tests hang because the server isn't closed. Either pass the app directly (supertest auto-creates and tears down the server) or close explicitly in afterAll.
Test data and fixtures
For tests that touch a database:
- Use a transactional rollback wrapper —
beforeEachopens a transaction,afterEachrolls back. Each test sees a clean DB. - Or: use Testcontainers for a real-flavored DB per suite. Slower but realistic. See the testcontainers skill.
- Avoid relying on data from previous tests — flakes and order-dependence follow.
- Don't share a global API key / user — create fresh users per test.
Error and edge cases
// Asserting an error status
await request(app).get('/users/nope').expect(404);
// Custom assertion in .expect(fn)
await request(app)
.get('/users/user-42')
.expect(200)
.expect((res) => {
if (!res.body.id) throw new Error('missing id');
});
// Per-request timeout
await request(app)
.get('/slow')
.timeout({ deadline: 5000 });
Common Pitfalls
- Forgetting
await/return— without one, the test ends before the promise settles, and assertion failures become unhandled rejections. ESLintno-floating-promisescatches most. - Not closing the server — Fastify/NestJS need explicit
app.close()or your test runner hangs. - Sharing state across tests — DB rows, in-memory caches, env vars. Reset per test.
- Using a real port — supertest assigns a random port for you; don't hardcode.
- Relying on
.end(cb)callbacks — they still work but mix poorly with async test runners; preferawait. - Asserting on entire JSON bodies — fragile. Use
toMatchObjector specific field checks. - Coupling tests to the app's exact middleware order — if you mock too aggressively, you stop testing the wired-up stack.
- Forgetting
.set('Content-Type', 'application/json')on raw.send(string)— if yousend(JSON.stringify(...))instead ofsend({...}), the content-type isn't auto-set. - Confusing in-process supertest with end-to-end testing — in-process tests skip the network stack. Add a smaller smoke layer that hits a real port for full integration coverage.
Task-Specific Questions
When helping with supertest, ask:
- Which Node framework — Express, Koa, Fastify, NestJS, Hapi, raw http?
- Test runner — Jest, Vitest, Mocha, Node's built-in?
- JS or TS?
- Does the API touch a real DB / cache / external services, or is it pure?
- Auth model — cookie session, JWT, basic, OAuth?
- Are you doing in-process tests, black-box tests against a deployed server, or both?
- What's your strategy for test isolation (transaction rollback, Testcontainers, fresh fixtures)?
Related Skills
- jest-vitest — for the surrounding test runner setup.
- pytest-api — Python equivalent.
- rest-assured — Java equivalent.
- postman-newman — when QA collections complement code tests.
- testcontainers — for spinning up real DBs / Kafka / Redis under tests.
- pact-contract-testing — for adding consumer-driven contracts on top of supertest.
- ci-test-orchestration — for parallelism and test isolation in CI.