API Testing with Supertest
Supertest tests HTTP endpoints by driving your Express (or any Node HTTP) app directly — making requests, asserting on status codes, headers, and response bodies. Crucially, it works without binding to a real network port: it hands your app object to Supertest, which starts it on an ephemeral port (or in-process), runs the request, and tears it down. This makes it the standard tool for integration-testing REST APIs: fast, realistic, and easy to combine with any test runner.
Setup — export the app separately from the server
app.js — export the app WITHOUT calling listen()
import express from 'express'
export const app = express()
app.use(express.json())
app.use('/api', router)
// NOTE: no app.listen() hereserver.js — the entry point that actually listens
import { app } from './app.js'
app.listen(3000, () => console.log('Listening on 3000'))A basic endpoint test
npm install --save-dev supertest
import request from 'supertest'
import { app } from '../app.js'
import assert from 'node:assert/strict'
import { test } from 'node:test'
test('GET /api/health returns 200 and ok status', async () => {
const res = await request(app)
.get('/api/health')
.expect(200)
.expect('Content-Type', /json/)
assert.equal(res.body.status, 'ok')
})POST with a body and headers
test('POST /api/users creates a user', async () => {
const res = await request(app)
.post('/api/users')
.set('Content-Type', 'application/json')
.send({ email: 'ada@x.com', name: 'Ada' })
.expect(201)
assert.equal(res.body.email, 'ada@x.com')
assert.ok(res.body.id)
assert.equal(res.body.passwordHash, undefined) // sensitive field NOT leaked
})
test('POST /api/users rejects invalid input', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'not-an-email' })
.expect(400)
assert.equal(res.body.code, 'VALIDATION_ERROR')
})Testing authenticated endpoints
import jwt from 'jsonwebtoken'
function authToken(payload) {
return jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '5m' })
}
test('GET /api/me requires authentication', async () => {
await request(app).get('/api/me').expect(401)
})
test('GET /api/me returns the user for a valid token', async () => {
const token = authToken({ sub: 42, role: 'user' })
const res = await request(app)
.get('/api/me')
.set('Authorization', `Bearer ${token}`)
.expect(200)
assert.equal(res.body.id, 42)
})
test('DELETE /api/users/:id is forbidden for non-admins', async () => {
const token = authToken({ sub: 2, role: 'user' })
await request(app)
.delete('/api/users/1')
.set('Authorization', `Bearer ${token}`)
.expect(403)
})Managing test database state
import { test, before, after, beforeEach } from 'node:test'
import { db } from '../db.js'
before(async () => { await db.migrate() }) // set up schema once
after(async () => { await db.close() }) // clean up the connection
beforeEach(async () => { await db.truncateAll() }) // clean slate per test
test('GET /api/posts returns only the current user\'s posts', async () => {
await db.posts.create({ title: 'mine', authorId: 42 })
await db.posts.create({ title: 'theirs', authorId: 99 })
const token = authToken({ sub: 42 })
const res = await request(app).get('/api/posts').set('Authorization', `Bearer ${token}`)
assert.equal(res.body.length, 1)
assert.equal(res.body[0].title, 'mine')
})What to assert on API responses
Status code — the right code for success, validation failure, auth failure, not-found.
Response shape — required fields present, correct types.
Sensitive fields absent —
passwordHash, internal IDs, tokens must NOT appear.Headers —
Content-Type, pagination headers, cache headers where relevant.Side effects — query the DB to confirm the data actually persisted/changed.
Error responses — the documented error
codeand message, not a stack trace.