Test and ship Express

Test the HTTP contract

Exercise the application as HTTP instead of calling route handlers like ordinary functions.

The thing clients depend on is HTTP: a method, a path, headers, a body in, a status and a body out. So that’s what I test. Send a real request through the real app, and assert on what comes back.

The alternative is calling a handler with a fake req and a fake res. It looks like a unit test, but you end up rebuilding Express in your mocks, and your test passes while the route order, the parser, or the error handler is broken.

Set up supertest

supertest starts the app on a random port for each request and tears it down. Nothing binds to 3000:

npm install --save-dev supertest

The app factory we built earlier is what makes this work. Tests build the app with fake dependencies and a test config:

import test from 'node:test'
import assert from 'node:assert/strict'
import request from 'supertest'
import { createApp } from '../src/app.js'

const config = { port: 0, sessionSecret: 'test-secret-test-secret-test-secret-1', production: false, trustProxy: false }

function fakeNotes(rows = []) {
  return {
    list: async () => rows,
    find: async id => rows.find(note => note.id === id) ?? null,
    create: async note => ({ id: 1, ...note }),
  }
}

No database, no network, no .env. The test owns every input.

Cover the contract, one case per outcome

The happy path first:

test('POST /api/notes creates a note', async () => {
  const app = createApp({ config, notes: fakeNotes() })

  const res = await request(app)
    .post('/api/notes')
    .send({ title: 'Buy milk', body: '' })

  assert.equal(res.status, 201)
  assert.equal(res.headers.location, '/api/notes/1')
  assert.equal(res.body.title, 'Buy milk')
})

.send() with an object sets Content-Type: application/json for you. Then the failures, which are where contracts usually break:

test('POST /api/notes rejects an empty title', async () => {
  const app = createApp({ config, notes: fakeNotes() })
  const res = await request(app).post('/api/notes').send({ title: '   ' })
  assert.equal(res.status, 400)
  assert.deepEqual(res.body.errors, ['title is required'])
})

test('GET /api/notes/:id returns 404 for a missing note', async () => {
  const app = createApp({ config, notes: fakeNotes([]) })
  const res = await request(app).get('/api/notes/9')
  assert.equal(res.status, 404)
})

test('a failing repository becomes a 500 without details', async () => {
  const notes = { ...fakeNotes(), list: async () => { throw new Error('connection refused') } }
  const app = createApp({ config, notes })
  const res = await request(app).get('/api/notes')
  assert.equal(res.status, 500)
  assert.equal(res.body.error, 'Internal error')
  assert.ok(!res.text.includes('connection refused'))
})

Run npm test:

✔ POST /api/notes creates a note (31ms)
✔ POST /api/notes rejects an empty title (6ms)
✔ GET /api/notes/:id returns 404 for a missing note (4ms)
✔ a failing repository becomes a 500 without details (5ms)
ℹ tests 4
ℹ pass 4

Four tests, whole app, under a second. Each one goes through the request id, the logger, the parser, the router, and the error handler. If any of those breaks, something here turns red.

Sessions in tests

For routes behind requireLogin, log in first and keep the cookie:

const agent = request.agent(app)
await agent.post('/login').send({ email: '[email protected]', password: 'correct-horse' })
const res = await agent.get('/notes')

An agent remembers cookies between requests, like a browser. This tests the real session middleware instead of faking req.session.

What stays a unit test

Title validation, the ownership rule, anything with no req or res in it. Those functions get plain tests with direct calls, and they run even faster. The split is the topic of the next lesson.

Try this on your project: pick one route and write the four tests above for it. If a case is hard to set up, that’s usually the app telling you a dependency should be injectable.

Lesson completed