9.1 Why Testing an Express App Is Different
Testing an Express application isn't like testing a function that takes an input and returns an output. Your application is a pipeline — requests flow through middleware, get validated, hit controllers, call services, touch databases, and produce responses. A bug can live anywhere in that chain, and the chain is what you have to test.
The good news: Express was designed to be testable, even if nobody told you that explicitly. The separation between app.js and bin/www that we covered in Topic 8 exists for exactly this reason. The application object is just a request handler. You can feed it requests without ever opening a network port.
That's the foundation of every testing strategy in this topic.
9.2 The Testing Stack
For Express, three tools cover most needs:
- Jest or Vitest — the test runner and assertion library. Both work well; Jest is more established, Vitest is faster and more modern.
- Supertest — sends HTTP requests directly to your Express app without starting a server.
- A test database — a separate database instance (or an in-memory one like
mongodb-memory-serveror SQLite) so tests don't touch production data.
Install them:
npm install --save-dev jest supertest
For TypeScript projects, add @types/jest and @types/supertest.
9.3 Integration Testing with Supertest
Supertest is the workhorse of Express testing. It wraps your app object and lets you make requests against it as if it were a running server — but without the overhead of one.
The core pattern:
const request = require('supertest');
const app = require('../app');
describe('GET /', () => {
it('responds with Hello World', async () => {
const response = await request(app).get('/');
expect(response.statusCode).toBe(200);
expect(response.text).toBe('Hello World!');
});
});
That's it. No app.listen(), no port, no cleanup. Supertest starts an ephemeral server internally, makes the request, and tears it down.
Testing Routes with Parameters and Bodies
describe('POST /users', () => {
it('creates a user and returns 201', async () => {
const response = await request(app)
.post('/users')
.send({ name: 'Alice', email: 'alice@example.com' });
expect(response.status).toBe(201);
expect(response.body.name).toBe('Alice');
expect(response.body.email).toBe('alice@example.com');
});
it('returns 400 for missing email', async () => {
const response = await request(app)
.post('/users')
.send({ name: 'Alice' });
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
});
});
The .send() method sets the request body and the Content-Type header automatically. For JSON, it serializes; for form data, use .type('form').
Testing Middleware
Middleware is testable the same way. If you have authentication middleware that rejects requests without a token, you test it by making a request without a token and asserting the status code.
describe('Authentication middleware', () => {
it('rejects requests without a token', async () => {
const response = await request(app).get('/protected');
expect(response.status).toBe(401);
});
it('allows requests with a valid token', async () => {
const response = await request(app)
.get('/protected')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(200);
});
});
Testing Error Handling
Error handlers are just middleware, and they're testable too. Trigger an error on purpose, then assert that the response is formatted correctly.
describe('Error handling', () => {
it('returns 500 with a generic message in production', async () => {
process.env.NODE_ENV = 'production';
const response = await request(app).get('/trigger-error');
expect(response.status).toBe(500);
expect(response.body.error).toBe('Internal Server Error');
expect(response.body.stack).toBeUndefined();
});
});
The last assertion matters. It verifies that production mode doesn't leak stack traces.
9.4 Unit Testing Services and Controllers
Integration tests cover the pipeline end to end. Unit tests cover the pieces in isolation.
Services are the easiest to unit test because they don't know about HTTP. They take inputs and return outputs (or throw errors), and you can mock their dependencies.
// services/userService.js
const User = require('../models/User');
exports.getUserById = async (id) => {
return User.findById(id).select('-password');
};
// tests/unit/userService.test.js
jest.mock('../models/User');
const User = require('../models/User');
const userService = require('../services/userService');
describe('userService.getUserById', () => {
it('returns the user without the password field', async () => {
User.findById.mockReturnValue({
select: jest.fn().mockResolvedValue({ _id: '1', name: 'Alice' }),
});
const user = await userService.getUserById('1');
expect(user.name).toBe('Alice');
expect(User.findById).toHaveBeenCalledWith('1');
});
});
Controllers are testable with mocked services. You construct fake req, res, and next objects, call the controller, and assert on what res received.
const userController = require('../controllers/userController');
const userService = require('../services/userService');
jest.mock('../services/userService');
describe('userController.getUser', () => {
it('returns 404 when the user does not exist', async () => {
userService.getUserById.mockResolvedValue(null);
const req = { params: { id: '1' } };
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
const next = jest.fn();
await userController.getUser(req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'User not found' });
});
});
This pattern is verbose but precise. It tests the controller's logic without touching the database or the HTTP layer.
9.5 Test Database Strategy
Every test that touches a database needs a strategy for isolation. Options:
In-memory databases. For MongoDB, mongodb-memory-server spins up a real MongoDB instance in memory for the test run. For SQL, SQLite in-memory mode works. Fast, clean, no external dependencies.
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
afterEach(async () => {
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
});
The afterEach block clears data between tests. Without it, tests leak state into each other and produce failures that depend on execution order.
A separate test database. If in-memory isn't an option, use a dedicated database instance for tests. The rule is absolute: tests must never touch production data.
9.6 What to Test
Not everything deserves a test. The goal is confidence, not coverage percentages.
Test the boundaries. Validation logic, authentication checks, error responses. These are where bugs hide and where security matters.
Test the happy path and the obvious failure paths. A route that creates a user should be tested with valid input and with input that's missing required fields.
Test business logic in services. Calculations, permissions, state transitions — these are the rules of your application, and they should be verified.
Don't test framework behavior. You don't need to test that express.json() parses JSON. That's Express's job. Test your code.
9.7 Preparing for Deployment
A deployed Express app needs a few things that a development app doesn't.
A Production Start Script
{
"scripts": {
"start": "NODE_ENV=production node ./bin/www",
"dev": "NODE_ENV=development node --watch ./bin/www",
"test": "jest"
}
}
The start script is what your hosting platform runs. It sets NODE_ENV and points at the entry file.
Handle SIGTERM Gracefully
When a deployment replaces your app, the platform sends SIGTERM to the old process. If you don't handle it, the process is killed abruptly, and in-flight requests are dropped.
// bin/www
const server = app.listen(port);
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
console.log('Process terminated');
process.exit(0);
});
});
server.close() stops accepting new connections and waits for in-flight requests to finish. Once they're done, the process exits cleanly.
Bind to the Right Port
Most platforms inject a PORT environment variable. Read it, don't hardcode it.
const port = process.env.PORT || 3000;
Don't Run as Root
If you're managing your own server, run the app as a non-privileged user. The systemd service file from Topic 7 includes User=www-data for this reason.
9.8 Deployment Options
Where you deploy determines what else you need to configure.
Platform-as-a-Service (PaaS)
Heroku, Render, Railway, Fly.io. You push code, they build and run it. The platform handles scaling, SSL, and process management. You provide a start script and a PORT-aware entry point.
The trade-off is cost and control. PaaS is the fastest path to a running app, but it's more expensive at scale and less configurable than running your own infrastructure.
Containers
A Dockerfile packages your app and its dependencies into an image:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "./bin/www"]
npm ci installs exactly the versions in package-lock.json, which is what you want in production. --omit=dev skips development dependencies.
The container runs anywhere Docker runs — a VPS, Kubernetes, ECS, Cloud Run. You're responsible for the orchestration layer, but you get portability and control.
Virtual Private Server (VPS)
You rent a Linux machine, install Node.js, clone your repo, and run the app under systemd (as covered in Topic 7). A reverse proxy — Nginx or Caddy — sits in front, handles SSL, serves static files, and forwards requests to your app.
This is the most manual option, and it's the right choice when you need full control or when the other options are too expensive for your scale.
Serverless
Vercel, Netlify Functions, AWS Lambda. Your Express app is wrapped in a function that runs per request. Cold starts are a concern, and long-running connections (WebSockets, SSE) aren't well supported.
Express works in serverless environments, but it's not the natural fit. If serverless is the target, consider frameworks designed for it.
9.9 Post-Deployment Checklist
Before calling a deployment "done":
NODE_ENV=productionis set. This controls view caching, error verbosity, and other Express behaviors.- Secrets are injected, not committed. Database URLs, API keys, JWT secrets come from the environment.
- SSL is enforced. Use HSTS (via Helmet) and redirect HTTP to HTTPS.
- Logs are collected. Console output disappears on restart. Ship logs to a persistent store.
- Health check endpoint exists. A simple
GET /healththat returns 200 lets load balancers and monitoring tools verify the app is alive. - Process manager is configured. systemd, PM2, or the platform's built-in manager — something restarts the app if it crashes.
- Graceful shutdown is handled.
SIGTERMtriggersserver.close(), not an abrupt kill.
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
});
The health check is small, but it's the difference between a load balancer routing traffic to a dead instance and routing it away.