Testing Express Apps¶
Untested code is broken code you haven't found yet
Express apps are highly testable because the middleware pattern lets you isolate every component. Test your routes in isolation with Supertest. Test your middleware in isolation with mocked req/res. Test your database layer with a test database. If you're not testing your error handler , your rating limiter , your auth middleware - you're deploying blind
Supertest for HTTP tests¶
Supertest spins up your Express app , sends HTTP requests , and asserts on responses
npm install --save-dev supertest jest
// src/app.js - MUST export without listening
const express = require('express')
const app = express()
app.use(express.json())
app.use('/users' , require('./routes/users'))
app.use(require('./middleware/errorHandler'))
module.exports = app // don't call app.listen() here
// server.js - entry point calls listen
const app = require('./src/app')
const PORT = process.env.PORT || 3000
app.listen(PORT)
// tests/users.test.js
const request = require('supertest')
const app = require('../src/app')
describe('GET /users' , () => {
it('returns user list' , async () => {
const res = await request(app)
.get('/users')
.expect(200)
.expect('Content-Type' , /json/)
expect(res.body.success).toBe(true)
expect(Array.isArray(res.body.data)).toBe(true)
})
it('returns 404 for non-existent user' , async () => {
const res = await request(app)
.get('/users/99999')
.expect(404)
expect(res.body.error).toBe('User not found')
})
})
describe('POST /users' , () => {
it('validates required fields' , async () => {
const res = await request(app)
.post('/users')
.send({ email: 'not-an-email' })
.expect(422)
expect(res.body.details).toBeDefined()
expect(res.body.details.length).toBeGreaterThan(0)
})
it('creates a user with valid data' , async () => {
const res = await request(app)
.post('/users')
.send({
email: 'test@example.com',
name: 'Test User',
password: 'Password123'
})
.expect(201)
expect(res.body.data).toHaveProperty('id')
})
})
Jest setup¶
// jest.config.js
module.exports = {
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.js'],
setupFilesAfterSetup: ['./tests/setup.js'],
coveragePathIgnorePatterns: ['/node_modules/']
}
// tests/setup.js - runs before all tests
beforeAll(async () => {
// connect to test database
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL
})
afterAll(async () => {
// close connections
})
Run tests:
npm test
# or with coverage
npx jest --coverage
mocking req/res for middleware tests¶
Test middleware in isolation without spinning up the full app:
// tests/middleware/auth.test.js
const { authenticateJWT } = require('../../src/middleware/auth')
describe('authenticateJWT' , () => {
it('returns 401 if no token provided' , () => {
const req = {
headers: {}
}
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
}
const next = jest.fn()
authenticateJWT(req , res , next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'No token provided' })
expect(next).not.toHaveBeenCalled()
})
it('calls next if token is valid' , () => {
const token = jwt.sign({ userId: 1 }, process.env.JWT_SECRET)
const req = {
headers: { authorization: `Bearer ${token}` }
}
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
}
const next = jest.fn()
authenticateJWT(req , res , next)
expect(next).toHaveBeenCalled()
expect(req.user.userId).toBe(1)
})
})
integration tests with test database¶
// tests/integration/user-flow.test.js
const request = require('supertest')
const app = require('../../src/app')
const { getPool } = require('../../src/config/database')
beforeEach(async () => {
// clean database before each test
const pool = getPool()
await pool.query('TRUNCATE users CASCADE')
})
afterAll(async () => {
const pool = getPool()
await pool.end()
})
describe('User CRUD flow' , () => {
let createdUserId
let authToken
it('completes full user lifecycle' , async () => {
// 1. Register
const registerRes = await request(app)
.post('/users')
.send({
email: 'test@example.com',
password: 'Password123',
name: 'Test User'
})
.expect(201)
createdUserId = registerRes.body.data.id
// 2. Login
const loginRes = await request(app)
.post('/auth/login')
.send({ email: 'test@example.com' , password: 'Password123' })
.expect(200)
authToken = loginRes.body.token
// 3. Get profile
const profileRes = await request(app)
.get(`/users/${createdUserId}`)
.set('Authorization' , `Bearer ${authToken}`)
.expect(200)
expect(profileRes.body.data.email).toBe('test@example.com')
// 4. Delete
await request(app)
.delete(`/users/${createdUserId}`)
.set('Authorization' , `Bearer ${authToken}`)
.expect(204)
})
})
testing error handler¶
// tests/middleware/errorHandler.test.js
const { errorHandler } = require('../../src/middleware/errorHandler')
const { AppError } = require('../../src/utils/errors')
describe('errorHandler' , () => {
let req , res , next
beforeEach(() => {
req = {}
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
}
next = jest.fn()
})
it('handles operational errors with correct status' , () => {
const err = new AppError('User not found' , 404)
errorHandler(err , req , res , next)
expect(res.status).toHaveBeenCalledWith(404)
expect(res.json).toHaveBeenCalledWith({ error: 'User not found' })
})
it('hides internal error details in production' , () => {
process.env.NODE_ENV = 'production'
const err = new Error('Database connection failed')
errorHandler(err , req , res , next)
expect(res.status).toHaveBeenCalledWith(500)
expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' })
})
it('includes stack trace in development' , () => {
process.env.NODE_ENV = 'development'
const err = new Error('Something broke')
errorHandler(err , req , res , next)
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ stack: expect.any(String) })
)
})
})
coverage requirements¶
// jest.config.js - minimum coverage thresholds
module.exports = {
collectCoverage: true,
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
collectCoverageFrom: [
'src/**/*.js',
'!src/server.js', // don't test entry point
'!src/config/**', // config is tested implicitly
'!**/node_modules/**'
]
}
prerequisites¶
express_15_rest_api.md - resource naming , status codes , pagination , versioning , Swagger
next → express_17_deployment.md