NodeJSAPI Testing with Supertest

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()

JS
import express from 'express'
export const app = express()
app.use(express.json())
app.use('/api', router)
// NOTE: no app.listen() here

server.js — the entry point that actually listens

JS
import { app } from './app.js'
app.listen(3000, () => console.log('Listening on 3000'))
Separate `app` from `listen()` — Supertest needs the app object, not a running server
The single most important setup detail: export the Express `app` from a module that does **not** call `app.listen()`, and do the `listen()` in a separate entry file. Supertest accepts the `app` object and manages binding itself. If your app file calls `listen()` on import, every test run tries to bind the port (causing `EADDRINUSE` errors) and you can't run tests in parallel. This separation is good architecture anyway — it decouples the app definition from how it's served.
A basic endpoint test

Bash
npm install --save-dev supertest

JS
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')
})
`request(app)` runs the full middleware stack — it's a real integration test
When you call `request(app).get('/api/health')`, the request passes through your entire middleware chain — body parsing, auth, validation, the route handler, and error handling — exactly as a real request would. That's what makes it an *integration* test: you're verifying the wiring, not a mocked stub. The `.expect()` chain asserts on status, headers, and body inline; the resolved `res` object gives you the full response for additional assertions.
POST with a body and headers

JS
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

JS
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)
})
Test the three auth cases for every protected route: no token, wrong role, valid
Supertest makes [protecting-routes](/nodejs/protecting-routes) testing trivial — sign a token with any payload and set the `Authorization` header. For each protected endpoint, write three tests: unauthenticated → 401, authenticated-but-wrong-role → 403, and correct credentials → success. These catch the most dangerous bug class: an endpoint that silently lacks its auth middleware and is world-accessible. A small helper that signs a token with arbitrary claims keeps the setup minimal.
Managing test database state

JS
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')
})
Reset database state between tests — shared state makes tests order-dependent and flaky
Integration tests share a real test database, so without cleanup, one test's data leaks into the next — tests pass or fail depending on the order they run, the definition of flaky. Truncate or reset tables in `beforeEach` so every test starts from a known clean state. Use a **dedicated test database** (never your dev or prod DB), ideally a disposable Docker container or an in-memory database, so a destructive truncation can't touch real data.
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 absentpasswordHash, internal IDs, tokens must NOT appear.

  • HeadersContent-Type, pagination headers, cache headers where relevant.

  • Side effects — query the DB to confirm the data actually persisted/changed.

  • Error responses — the documented error code and message, not a stack trace.

Next
Isolate units from their dependencies: [Mocking & Stubbing](/nodejs/mocking).