Skip to content

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