I think I've reached the setup I wanted:
await
everywhere, no infinitely deeply nested callbacks
- no external libraries besides mocha and express, an analogous setup would work for other systems
- no mocking. No mocking extra effort. It just starts a clean server on a random port for each test, and closes the server at the end of the test, exactly like the real thing
Not shown in this example, you would want to finish things of by running the app with a temporary in-memory SQLite database unique to every test when NODE_ENV=test
is used. The real production server would run on something like PostgreSQL, and an ORM like sequelize would be used so that the same code works on both. Or you could have a setup that creates the DB once and truncates all tables before each test.
app.js
#!/usr/bin/env node
const express = require('express')
async function start(port, cb) {
const app = express()
app.get('/', (req, res) => {
res.send(`asdf`)
})
app.get('/qwer', (req, res) => {
res.send(`zxcv`)
})
return new Promise((resolve, reject) => {
const server = app.listen(port, async function() {
try {
cb && await cb(server)
} catch (e) {
reject(e)
this.close()
throw e
}
})
server.on('close', resolve)
})
}
if (require.main === module) {
start(3000, server => {
console.log('Listening on: http://localhost:' + server.address().port)
})
}
module.exports = { start }
test.js
const assert = require('assert');
const http = require('http')
const app = require('./app')
function testApp(cb) {
return app.start(0, async (server) => {
await cb(server)
server.close()
})
}
// https://stackoverflow.com/questions/6048504/synchronous-request-in-node-js/53338670#53338670
function sendJsonHttp(opts) {
return new Promise((resolve, reject) => {
try {
let body
if (opts.body) {
body = JSON.stringify(opts.body)
} else {
body = ''
}
const headers = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'Accept': 'application/json',
}
if (opts.token) {
headers['Authorization'] = `Token ${opts.token}`
}
const options = {
hostname: 'localhost',
port: opts.server.address().port,
path: opts.path,
method: opts.method,
headers,
}
const req = http.request(options, res => {
res.on('data', data => {
let dataString
let ret
try {
dataString = data.toString()
if (res.headers['content-type'].startsWith('application/json;')) {
ret = JSON.parse(dataString)
} else {
ret = dataString
}
resolve([res, ret])
} catch (e) {
console.error({ dataString });
reject(e)
}
})
// We need this as there is no 'data' event empty reply, e.g. a DELETE 204.
res.on('end', () => resolve([ res, undefined ]))
})
req.write(body)
req.end()
} catch (e) {
reject(e)
}
})
}
it('test root', () => {
// When an async function is used, Mocha waits for the promise to resolve
// before deciding pass/fail.
return testApp(async (server) => {
let res, data
// First request, normally a POST that changes state.
;[res, data] = await sendJsonHttp({
server,
method: 'GET',
path: '/',
body: {},
})
assert.strictEqual(res.statusCode, 200)
assert.strictEqual(data, 'asdf')
// Second request, normally a GET to check that POST.
;[res, data] = await sendJsonHttp({
server,
method: 'GET',
path: '/',
body: {},
})
assert.strictEqual(res.statusCode, 200)
assert.strictEqual(data, 'asdf')
})
})
it('test /qwer', () => {
return testApp(async (server) => {
let res, data
;[res, data] = await sendJsonHttp({
server,
method: 'GET',
path: '/qwer',
body: {},
})
assert.strictEqual(res.statusCode, 200)
assert.strictEqual(data, 'zxcv')
})
})
package.json
{
"name": "tmp",
"version": "1.0.0",
"dependencies": {
"express": "4.17.1"
},
"devDependencies": {
"mocha": "6.2.2"
},
"scripts": {
"test": "mocha test test.js"
}
}
With this, running:
npm install
./app
runs the server normally as desired. And running:
npm test
makes the tests work as desired. Notably, if you hack any of the asserts to wrong values, they will throw, the server closes without hanging, and at the end failing tests show as failing.
The async http requests are also mentioned at: Synchronous request in Node.js
Tested on Node.js 14.17.0, Ubuntu 21.10.