Skip to main content

Express Test-Driven Development

Introduction

Test-Driven Development (TDD) is a software development approach where tests are written before the actual code. In the context of Express.js applications, this means creating tests for your routes, middleware, and controllers before implementing them. This approach leads to more robust code, better design, and fewer bugs.

In this guide, we'll explore how to apply TDD principles to Express.js development, using popular testing tools like Jest and Supertest.

What is Test-Driven Development?

Test-Driven Development follows a simple cycle known as "Red-Green-Refactor":

  1. Red: Write a failing test that defines the desired functionality
  2. Green: Write the minimum code necessary to make the test pass
  3. Refactor: Improve the code while ensuring the tests still pass

This approach helps you focus on requirements first and implementation second, leading to cleaner code that fulfills its intended purpose.

Setting Up Your Testing Environment

Before diving into TDD, let's set up a basic testing environment for an Express application.

Prerequisites

You'll need:

  • Node.js and npm installed
  • Basic understanding of Express.js
  • Familiarity with JavaScript testing concepts

Initial Setup

First, let's create a new Express project and install the necessary dependencies:

bash
# Create a new project
mkdir express-tdd-example
cd express-tdd-example
npm init -y

# Install Express and testing dependencies
npm install express
npm install --save-dev jest supertest

Update your package.json to include test scripts:

json
{
"scripts": {
"test": "jest --detectOpenHandles",
"test:watch": "jest --watch --detectOpenHandles"
},
"jest": {
"testEnvironment": "node"
}
}

Your First TDD Cycle in Express

Let's implement a simple user API using TDD principles.

Step 1: Create Project Structure

express-tdd-example/
├── src/
│ ├── app.js
│ └── routes/
│ └── userRoutes.js
└── tests/
└── user.test.js

Step 2: Write Your First Test (Red Phase)

Create tests/user.test.js:

javascript
const request = require('supertest');
const app = require('../src/app');

describe('User API', () => {
describe('GET /api/users', () => {
it('should return a list of users', async () => {
const response = await request(app).get('/api/users');

expect(response.status).toBe(200);
expect(response.body).toBeInstanceOf(Array);
expect(response.body.length).toBeGreaterThanOrEqual(0);
});
});
});

When you run this test with npm test, it will fail because we haven't created our Express app yet.

Step 3: Create Minimal Express App (Green Phase)

Let's create the app structure to make our test pass:

Create src/app.js:

javascript
const express = require('express');
const app = express();

app.use(express.json());

// Import routes
const userRoutes = require('./routes/userRoutes');

// Apply routes
app.use('/api/users', userRoutes);

module.exports = app;

Create src/routes/userRoutes.js:

javascript
const express = require('express');
const router = express.Router();

// Temporary in-memory data store
const users = [];

// GET all users
router.get('/', (req, res) => {
res.status(200).json(users);
});

module.exports = router;

Now when you run npm test, the test should pass!

Step 4: Refactor (If Needed)

In this simple example, there's not much to refactor. But in a real-world application, you might want to:

  • Separate the data store from routes
  • Add proper error handling
  • Improve the organization of the code

Expanding Our API with TDD

Let's continue building our API by adding the ability to create users.

Step 1: Write a Test for Creating Users

Add this to your user.test.js file:

javascript
describe('POST /api/users', () => {
it('should create a new user', async () => {
const newUser = {
name: 'John Doe',
email: '[email protected]'
};

const response = await request(app)
.post('/api/users')
.send(newUser);

expect(response.status).toBe(201);
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe(newUser.name);
expect(response.body.email).toBe(newUser.email);

// Verify the user was actually created
const allUsersResponse = await request(app).get('/api/users');
const createdUser = allUsersResponse.body.find(u => u.id === response.body.id);
expect(createdUser).toBeTruthy();
});

it('should return 400 if user data is incomplete', async () => {
const incompleteUser = {
name: 'Jane Doe'
// Missing email
};

const response = await request(app)
.post('/api/users')
.send(incompleteUser);

expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
});

Step 2: Implement the POST Endpoint

Update src/routes/userRoutes.js:

javascript
const express = require('express');
const router = express.Router();

// Temporary in-memory data store
const users = [];
let nextId = 1;

// GET all users
router.get('/', (req, res) => {
res.status(200).json(users);
});

// POST create a new user
router.post('/', (req, res) => {
const { name, email } = req.body;

// Validate input
if (!name || !email) {
return res.status(400).json({ error: 'Name and email are required' });
}

// Create new user
const newUser = {
id: nextId++,
name,
email,
createdAt: new Date()
};

// Add to "database"
users.push(newUser);

// Return the created user
res.status(201).json(newUser);
});

module.exports = router;

Now your tests should pass!

Testing Middleware with TDD

Middleware functions are a key part of Express applications. Let's create a simple authentication middleware using TDD.

Step 1: Write Tests for Authentication Middleware

Create a new test file tests/authMiddleware.test.js:

javascript
const request = require('supertest');
const express = require('express');
const authMiddleware = require('../src/middleware/authMiddleware');

describe('Authentication Middleware', () => {
let app;

beforeEach(() => {
// Create a fresh app for each test
app = express();

// Set up a protected route
app.use('/protected', authMiddleware, (req, res) => {
res.status(200).json({ message: 'Access granted', user: req.user });
});
});

it('should allow access with valid token', async () => {
const response = await request(app)
.get('/protected')
.set('Authorization', 'Bearer valid-token');

expect(response.status).toBe(200);
expect(response.body.message).toBe('Access granted');
expect(response.body.user).toEqual({ id: 1, name: 'Test User' });
});

it('should deny access without a token', async () => {
const response = await request(app)
.get('/protected');

expect(response.status).toBe(401);
expect(response.body).toHaveProperty('error');
});

it('should deny access with invalid token', async () => {
const response = await request(app)
.get('/protected')
.set('Authorization', 'Bearer invalid-token');

expect(response.status).toBe(403);
expect(response.body).toHaveProperty('error');
});
});

Step 2: Implement the Authentication Middleware

Create src/middleware/authMiddleware.js:

javascript
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;

if (!authHeader) {
return res.status(401).json({ error: 'Authorization header missing' });
}

const [authType, token] = authHeader.split(' ');

if (authType !== 'Bearer') {
return res.status(401).json({ error: 'Invalid authorization format' });
}

if (token !== 'valid-token') {
return res.status(403).json({ error: 'Invalid or expired token' });
}

// In a real app, you would verify the token and fetch user data
req.user = { id: 1, name: 'Test User' };
next();
}

module.exports = authMiddleware;

Once implemented, your middleware tests should pass.

Advanced TDD: Testing Database Interactions

In real-world applications, you'll often interact with databases. Here's how to apply TDD to database operations.

Setting Up a Test Database

For this example, we'll use an in-memory MongoDB database for testing. First, install the necessary packages:

bash
npm install mongoose
npm install --save-dev mongodb-memory-server

Create a Test Helper

Create tests/dbHandler.js:

javascript
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');

let mongoServer;

// Connect to the in-memory database
module.exports.connect = async () => {
mongoServer = await MongoMemoryServer.create();
const uri = mongoServer.getUri();
await mongoose.connect(uri);
};

// Drop database, close connection and stop server
module.exports.closeDatabase = async () => {
await mongoose.connection.dropDatabase();
await mongoose.connection.close();
await mongoServer.stop();
};

// Clear all data in all collections
module.exports.clearDatabase = async () => {
const collections = mongoose.connection.collections;

for (const key in collections) {
const collection = collections[key];
await collection.deleteMany({});
}
};

Write Tests for Database Operations

Create tests/userModel.test.js:

javascript
const dbHandler = require('./dbHandler');
const User = require('../src/models/User');

describe('User Model', () => {
beforeAll(async () => await dbHandler.connect());
afterEach(async () => await dbHandler.clearDatabase());
afterAll(async () => await dbHandler.closeDatabase());

it('should create & save a user successfully', async () => {
const userData = {
name: 'Test User',
email: '[email protected]',
password: 'password123'
};

const validUser = new User(userData);
const savedUser = await validUser.save();

// Object ID should be defined when successfully saved
expect(savedUser._id).toBeDefined();
expect(savedUser.name).toBe(userData.name);
expect(savedUser.email).toBe(userData.email);
// Password should be hashed and not equal to original
expect(savedUser.password).not.toBe(userData.password);
});

it('should fail when email is missing', async () => {
const userWithoutEmail = new User({
name: 'Test User',
password: 'password123'
});

let err;
try {
await userWithoutEmail.save();
} catch (error) {
err = error;
}

expect(err).toBeInstanceOf(mongoose.Error.ValidationError);
});
});

Implement User Model

Create src/models/User.js:

javascript
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const userSchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true
},
email: {
type: String,
required: true,
unique: true,
trim: true,
lowercase: true
},
password: {
type: String,
required: true,
minlength: 6
},
createdAt: {
type: Date,
default: Date.now
}
});

// Hash the password before saving
userSchema.pre('save', async function(next) {
if (this.isModified('password')) {
this.password = await bcrypt.hash(this.password, 8);
}
next();
});

const User = mongoose.model('User', userSchema);

module.exports = User;

Don't forget to install bcryptjs:

bash
npm install bcryptjs

Integration Testing with TDD

Let's take our TDD approach and apply it to integration testing, where we test multiple components working together.

Write Integration Test

Create tests/integration.test.js:

javascript
const request = require('supertest');
const app = require('../src/app');
const dbHandler = require('./dbHandler');

describe('User API Integration', () => {
beforeAll(async () => await dbHandler.connect());
afterEach(async () => await dbHandler.clearDatabase());
afterAll(async () => await dbHandler.closeDatabase());

it('should create a user and then fetch it', async () => {
// First create a user
const userData = {
name: 'Integration Test User',
email: '[email protected]',
password: 'secure123'
};

const createResponse = await request(app)
.post('/api/users')
.send(userData);

expect(createResponse.status).toBe(201);
const userId = createResponse.body.id;

// Then fetch the user
const getResponse = await request(app)
.get(`/api/users/${userId}`);

expect(getResponse.status).toBe(200);
expect(getResponse.body.name).toBe(userData.name);
expect(getResponse.body.email).toBe(userData.email);
// Password should not be returned
expect(getResponse.body.password).toBeUndefined();
});
});

Implement the GET User by ID Endpoint

Update src/routes/userRoutes.js to support fetching a user by ID:

javascript
// GET user by ID
router.get('/:id', async (req, res) => {
try {
const user = await User.findById(req.params.id).select('-password');
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.status(200).json(user);
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});

Make sure to update your app.js to connect to the database before starting the server.

Benefits of TDD in Express Development

Using TDD with Express provides several advantages:

  1. Better Code Quality: Writing tests first forces you to think about your design and requirements
  2. Documentation: Tests serve as documentation showing how your API should work
  3. Fewer Bugs: TDD helps catch bugs early in the development process
  4. Refactoring Confidence: Having tests gives you confidence when making changes
  5. Improved API Design: You build exactly what's needed, resulting in cleaner interfaces

Best Practices for Express TDD

  1. Start Simple: Begin with the simplest test that fails, then implement the minimum code to make it pass
  2. Focus on Behavior: Test the behavior of your API, not implementation details
  3. Keep Tests Fast: Tests should run quickly to maintain a tight feedback loop
  4. Use Realistic Data: Create helper functions to generate test data that mimics real usage
  5. Isolate Tests: Each test should be independent and not affect other tests
  6. Use Continuous Integration: Run tests automatically on every code change

Real-World Example: Building a Complete API

Let's tie everything together with a practical example of building a RESTful API for a simple blog:

  1. First, write tests for each endpoint:

    • GET /api/posts (list all posts)
    • GET /api/posts/:id (get a specific post)
    • POST /api/posts (create a new post)
    • PUT /api/posts/:id (update a post)
    • DELETE /api/posts/:id (delete a post)
  2. Then implement the routes to make the tests pass

  3. Add authentication middleware for protected routes

  4. Refactor and optimize while ensuring tests continue to pass

Summary

Test-Driven Development is a powerful approach to building Express applications. By writing tests first, you ensure that your code meets requirements and remains maintainable as it grows. In this guide, we've covered:

  • The basics of TDD and the Red-Green-Refactor cycle
  • Setting up a testing environment for Express
  • Writing tests for routes, middleware, and models
  • Integration testing for Express applications
  • Best practices for applying TDD to Express development

Additional Resources

Practice Exercises

  1. Implement TDD for user authentication routes (login/signup)
  2. Add validation middleware using TDD
  3. Create tests and implementation for a comment system for the blog API
  4. Write tests for error handling middleware
  5. Implement pagination for the GET /api/posts endpoint using TDD

By practicing TDD in your Express projects, you'll develop the skills to build more robust, maintainable APIs that perfectly match your requirements.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)