Express Test Automation
Testing is a crucial part of developing robust web applications. By automating your tests, you can ensure your Express.js applications work as expected and continue to function correctly as you add new features or refactor existing code. In this guide, we'll explore how to set up and implement automated testing for your Express applications.
Introduction to Express Test Automation
Test automation refers to the practice of writing scripts that automatically test your application's functionality. For Express.js applications, test automation typically involves:
- Unit Tests - Testing individual functions and components
- Integration Tests - Testing how components work together
- API Tests - Testing your Express routes and HTTP endpoints
- End-to-End Tests - Testing entire user flows through your application
Automated tests serve as a safety net that helps catch bugs early, enables confident refactoring, and documents how your code is supposed to function.
Setting Up Your Testing Environment
Before we dive into writing tests, let's set up a testing environment for an Express application.
Installing Testing Dependencies
We'll use the following popular testing tools:
- Mocha - A flexible testing framework
- Chai - An assertion library
- Supertest - A library for testing HTTP endpoints
npm install --save-dev mocha chai supertest
Creating a Basic Express App for Testing
Let's create a simple Express application to test:
// app.js
const express = require('express');
const app = express();
app.use(express.json());
// Simple in-memory "database"
let users = [
{ id: 1, name: 'John Doe', email: '[email protected]' },
{ id: 2, name: 'Jane Smith', email: '[email protected]' }
];
// Routes
app.get('/api/users', (req, res) => {
res.json(users);
});
app.get('/api/users/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) return res.status(404).json({ message: 'User not found' });
res.json(user);
});
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ message: 'Name and email are required' });
}
const newUser = {
id: users.length + 1,
name,
email
};
users.push(newUser);
res.status(201).json(newUser);
});
// For testing purposes, we'll export the app without starting it
module.exports = app;
// When not testing, start the server
if (process.env.NODE_ENV !== 'test') {
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
}
Setting Up Test Configuration
Create a test folder and add a test configuration file:
// test/setup.js
process.env.NODE_ENV = 'test';
Update your package.json
to include test scripts:
{
"scripts": {
"test": "mocha --exit test/**/*.test.js",
"test:watch": "mocha --watch test/**/*.test.js"
}
}
Writing Your First API Tests with Supertest
Let's start by writing tests for our API endpoints using Supertest:
// test/api.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../app');
describe('User API', () => {
describe('GET /api/users', () => {
it('should return all users', async () => {
const res = await request(app)
.get('/api/users')
.expect(200);
expect(res.body).to.be.an('array');
expect(res.body.length).to.be.at.least(2);
expect(res.body[0]).to.have.property('name');
expect(res.body[0]).to.have.property('email');
});
});
describe('GET /api/users/:id', () => {
it('should return a user if valid id is passed', async () => {
const res = await request(app)
.get('/api/users/1')
.expect(200);
expect(res.body).to.have.property('name', 'John Doe');
expect(res.body).to.have.property('email', '[email protected]');
});
it('should return 404 if invalid id is passed', async () => {
const res = await request(app)
.get('/api/users/9999')
.expect(404);
expect(res.body).to.have.property('message', 'User not found');
});
});
describe('POST /api/users', () => {
it('should create a new user when valid data is provided', async () => {
const userData = {
name: 'Test User',
email: '[email protected]'
};
const res = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
expect(res.body).to.have.property('id');
expect(res.body).to.have.property('name', userData.name);
expect(res.body).to.have.property('email', userData.email);
});
it('should return 400 if name is missing', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: '[email protected]' })
.expect(400);
expect(res.body).to.have.property('message');
});
});
});
When you run npm test
, Mocha executes these tests and provides output showing which tests passed or failed.
Testing with a Mock Database
In real-world applications, you'll want to test your Express routes without touching your production database. Let's see how to implement testing with a mock database:
Using a Mock Database for Testing
// db.js
const users = [
{ id: 1, name: 'John Doe', email: '[email protected]' },
{ id: 2, name: 'Jane Smith', email: '[email protected]' }
];
module.exports = {
getUsers: () => Promise.resolve([...users]),
getUserById: (id) => Promise.resolve(users.find(u => u.id === parseInt(id)) || null),
createUser: (userData) => {
const newUser = { id: users.length + 1, ...userData };
users.push(newUser);
return Promise.resolve(newUser);
}
};
Now modify the app.js to use this database:
// app.js
const express = require('express');
const db = require('./db');
const app = express();
app.use(express.json());
app.get('/api/users', async (req, res) => {
const users = await db.getUsers();
res.json(users);
});
app.get('/api/users/:id', async (req, res) => {
const user = await db.getUserById(req.params.id);
if (!user) return res.status(404).json({ message: 'User not found' });
res.json(user);
});
app.post('/api/users', async (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ message: 'Name and email are required' });
}
const newUser = await db.createUser({ name, email });
res.status(201).json(newUser);
});
module.exports = app;
For testing, you can use a test-specific mock or even create a separate in-memory database for testing.
Using Test Hooks for Setup and Teardown
Mocha provides hooks like before
, beforeEach
, after
, and afterEach
that help with test setup and teardown:
// test/api.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../app');
const db = require('../db');
describe('User API', () => {
let originalUsers;
// Save original users before tests
before(async () => {
originalUsers = await db.getUsers();
});
// Reset users after all tests
after(async () => {
// In a real app, you might reset the database here
console.log('Tests completed');
});
// Reset users before each test if needed
beforeEach(async () => {
// You could reset to a known state before each test
});
// Your tests here...
});
Testing Express Middleware
Testing middleware functions requires a slightly different approach:
// middleware/auth.js
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Unauthorized' });
}
const token = authHeader.split(' ')[1];
if (token !== 'valid-token') {
return res.status(401).json({ message: 'Invalid token' });
}
req.user = { id: 1, name: 'Authenticated User' };
next();
}
module.exports = authMiddleware;
Now let's test this middleware:
// test/middleware.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const authMiddleware = require('../middleware/auth');
describe('Auth Middleware', () => {
it('should call next() if valid token is provided', () => {
const req = {
headers: {
authorization: 'Bearer valid-token'
}
};
const res = {};
const next = sinon.spy();
authMiddleware(req, res, next);
expect(next.calledOnce).to.be.true;
expect(req.user).to.have.property('id', 1);
expect(req.user).to.have.property('name', 'Authenticated User');
});
it('should return 401 if no token is provided', () => {
const req = {
headers: {}
};
const res = {
status: sinon.stub().returnsThis(),
json: sinon.spy()
};
const next = sinon.spy();
authMiddleware(req, res, next);
expect(res.status.calledWith(401)).to.be.true;
expect(res.json.calledOnce).to.be.true;
expect(next.called).to.be.false;
});
it('should return 401 if invalid token is provided', () => {
const req = {
headers: {
authorization: 'Bearer invalid-token'
}
};
const res = {
status: sinon.stub().returnsThis(),
json: sinon.spy()
};
const next = sinon.spy();
authMiddleware(req, res, next);
expect(res.status.calledWith(401)).to.be.true;
expect(res.json.calledOnce).to.be.true;
expect(next.called).to.be.false;
});
});
Testing with Jest
While we've been using Mocha and Chai, Jest is another popular testing framework that works well with Express. Here's how you can set it up:
npm install --save-dev jest supertest
Update your package.json
:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watchAll"
}
}
And here's an example test using Jest:
// __tests__/api.test.js
const request = require('supertest');
const app = require('../app');
describe('User API', () => {
describe('GET /api/users', () => {
it('should return all users', async () => {
const res = await request(app).get('/api/users');
expect(res.statusCode).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body.length).toBeGreaterThanOrEqual(2);
expect(res.body[0]).toHaveProperty('name');
expect(res.body[0]).toHaveProperty('email');
});
});
// Other tests similarly converted to Jest syntax...
});
Continuous Integration
To fully embrace automated testing, set up Continuous Integration (CI) to run your tests automatically when you push code to your repository. Here's a basic GitHub Actions workflow:
# .github/workflows/test.yml
name: Run Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v2
with:
node-version: '16'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
Real-World Testing Example: User Authentication Flow
Let's create a more comprehensive test for a user authentication flow:
// test/auth.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../app');
describe('Authentication Flow', () => {
const testUser = {
email: '[email protected]',
password: 'password123',
name: 'Test User'
};
let authToken;
it('should register a new user', async () => {
const res = await request(app)
.post('/api/auth/register')
.send(testUser)
.expect(201);
expect(res.body).to.have.property('id');
expect(res.body).to.have.property('name', testUser.name);
expect(res.body).to.have.property('email', testUser.email);
expect(res.body).to.not.have.property('password');
});
it('should not register a user with an existing email', async () => {
const res = await request(app)
.post('/api/auth/register')
.send(testUser)
.expect(400);
expect(res.body).to.have.property('message');
expect(res.body.message).to.include('already exists');
});
it('should login a user with valid credentials', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({
email: testUser.email,
password: testUser.password
})
.expect(200);
expect(res.body).to.have.property('token');
authToken = res.body.token;
});
it('should not login a user with invalid credentials', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({
email: testUser.email,
password: 'wrongpassword'
})
.expect(401);
expect(res.body).to.have.property('message');
expect(res.body.message).to.include('Invalid credentials');
});
it('should access protected routes with valid token', async () => {
const res = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(res.body).to.have.property('name', testUser.name);
expect(res.body).to.have.property('email', testUser.email);
});
it('should not access protected routes without token', async () => {
const res = await request(app)
.get('/api/profile')
.expect(401);
expect(res.body).to.have.property('message');
expect(res.body.message).to.include('Unauthorized');
});
});
Test Coverage
To measure how much of your code is covered by tests, you can use tools like Istanbul/nyc:
npm install --save-dev nyc
Update your package.json
:
{
"scripts": {
"test": "mocha --exit test/**/*.test.js",
"test:coverage": "nyc --reporter=html --reporter=text mocha --exit test/**/*.test.js"
}
}
Run npm run test:coverage
to generate a coverage report that shows which parts of your code are well-tested and which need more tests.
Summary
Express test automation is essential for building reliable web applications. In this guide, we've covered:
- Setting up a testing environment with Mocha, Chai, and Supertest
- Writing API tests for your Express routes
- Testing with mocked databases
- Using test hooks for setup and teardown
- Testing middleware functions
- Alternative testing with Jest
- Setting up Continuous Integration
- Testing real-world authentication flows
- Measuring test coverage
By integrating automated testing into your Express.js development workflow, you'll catch bugs early, refactor with confidence, and build more robust web applications.
Further Resources and Exercises
Resources
- Mocha Documentation
- Chai Assertion Library
- Supertest Documentation
- Jest Documentation
- Express.js Testing Best Practices
Exercises
-
Basic API Testing: Write tests for a CRUD API that manages a collection of products.
-
Error Handling Tests: Create tests that verify your Express app handles errors correctly, including validation errors, not found errors, and server errors.
-
Authentication Middleware: Implement and test JWT authentication middleware.
-
Database Integration: Set up tests that use a test database (like MongoDB Memory Server or SQLite) instead of mocking.
-
Performance Testing: Write tests that verify your API responses are returned within an acceptable time frame.
By practicing these exercises, you'll build confidence in your Express test automation skills and ensure your applications are robust and reliable.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)