Behavior-Driven Development with Mocha, Chai & Sinon
This project demonstrates Behavior-Driven Development (BDD) testing for a complete user authentication system using Mocha, Chai, and Sinon. It covers user registration, login, JWT token management, password recovery, and session handling with expressive, human-readable test cases.
BDD focuses on describing system behavior in plain language that stakeholders can understand, making tests serve as living documentation. This approach bridges the gap between technical implementation and business requirements.
BDD tests are written in a narrative style using Given-When-Then structure:
Given a user with valid credentials exists in the database
When the user attempts to login with correct email and password
Then the system should return a valid JWT token and user information
Try out the authentication system features below:
Comprehensive BDD test suite using Mocha's expressive syntax with Chai assertions:
// test/auth.spec.js
const { expect } = require('chai');
const sinon = require('sinon');
const request = require('supertest');
const app = require('../app');
const User = require('../models/User');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
describe('User Authentication System', function() {
describe('Feature: User Registration', function() {
context('Given valid user data', function() {
it('should successfully register a new user', async function() {
const userData = {
name: 'John Doe',
email: 'john@example.com',
password: 'SecurePass123!'
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(201);
expect(response.body).to.have.property('success', true);
expect(response.body.data).to.have.property('token');
expect(response.body.data.user).to.include({
name: userData.name,
email: userData.email
});
expect(response.body.data.user).to.not.have.property('password');
});
it('should hash the password before storing', async function() {
const userData = {
name: 'Jane Doe',
email: 'jane@example.com',
password: 'PlainPassword123'
};
await request(app)
.post('/api/auth/register')
.send(userData)
.expect(201);
const user = await User.findOne({ email: userData.email });
expect(user.password).to.not.equal(userData.password);
const isMatch = await bcrypt.compare(userData.password, user.password);
expect(isMatch).to.be.true;
});
it('should return a valid JWT token', async function() {
const userData = {
name: 'Test User',
email: 'test@example.com',
password: 'TestPass123!'
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(201);
const token = response.body.data.token;
const decoded = jwt.verify(token, process.env.JWT_SECRET);
expect(decoded).to.have.property('userId');
expect(decoded).to.have.property('email', userData.email);
});
});
context('Given invalid user data', function() {
it('should reject registration with missing email', async function() {
const userData = {
name: 'John Doe',
password: 'SecurePass123!'
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(400);
expect(response.body.success).to.be.false;
expect(response.body.error).to.match(/email.*required/i);
});
it('should reject registration with invalid email format', async function() {
const userData = {
name: 'John Doe',
email: 'invalid-email',
password: 'SecurePass123!'
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(400);
expect(response.body.error).to.match(/valid email/i);
});
it('should reject weak passwords', async function() {
const userData = {
name: 'John Doe',
email: 'john@example.com',
password: '123'
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(400);
expect(response.body.error).to.match(/password.*8 characters/i);
});
it('should reject duplicate email registration', async function() {
const userData = {
name: 'John Doe',
email: 'duplicate@example.com',
password: 'SecurePass123!'
};
// First registration
await request(app)
.post('/api/auth/register')
.send(userData)
.expect(201);
// Duplicate registration
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(400);
expect(response.body.error).to.match(/email.*already exists/i);
});
});
});
describe('Feature: User Login', function() {
beforeEach(async function() {
// Seed a test user
await User.create({
name: 'Test User',
email: 'test@example.com',
password: await bcrypt.hash('password123', 10)
});
});
context('Given valid credentials', function() {
it('should successfully login with correct email and password', async function() {
const credentials = {
email: 'test@example.com',
password: 'password123'
};
const response = await request(app)
.post('/api/auth/login')
.send(credentials)
.expect(200);
expect(response.body.success).to.be.true;
expect(response.body.data).to.have.property('token');
expect(response.body.data.user).to.include({
email: credentials.email
});
});
it('should return a fresh JWT token on login', async function() {
const before = Date.now();
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'test@example.com',
password: 'password123'
})
.expect(200);
const decoded = jwt.verify(response.body.data.token, process.env.JWT_SECRET);
expect(decoded.iat * 1000).to.be.at.least(before);
});
});
context('Given invalid credentials', function() {
it('should reject login with incorrect password', async function() {
const credentials = {
email: 'test@example.com',
password: 'wrongpassword'
};
const response = await request(app)
.post('/api/auth/login')
.send(credentials)
.expect(401);
expect(response.body.success).to.be.false;
expect(response.body.error).to.match(/invalid credentials/i);
});
it('should reject login with non-existent email', async function() {
const credentials = {
email: 'nonexistent@example.com',
password: 'password123'
};
const response = await request(app)
.post('/api/auth/login')
.send(credentials)
.expect(401);
expect(response.body.error).to.match(/invalid credentials/i);
});
it('should not reveal if email exists when login fails', async function() {
const response1 = await request(app)
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'wrong' })
.expect(401);
const response2 = await request(app)
.post('/api/auth/login')
.send({ email: 'fake@example.com', password: 'wrong' })
.expect(401);
// Both should return same generic error
expect(response1.body.error).to.equal(response2.body.error);
});
});
context('Given account lockout scenario', function() {
it('should lock account after 5 failed attempts', async function() {
const credentials = {
email: 'test@example.com',
password: 'wrongpassword'
};
// Make 5 failed attempts
for (let i = 0; i < 5; i++) {
await request(app)
.post('/api/auth/login')
.send(credentials)
.expect(401);
}
// 6th attempt should be locked
const response = await request(app)
.post('/api/auth/login')
.send(credentials)
.expect(403);
expect(response.body.error).to.match(/account locked/i);
});
});
});
describe('Feature: JWT Token Validation', function() {
let validToken;
let userId;
beforeEach(async function() {
const user = await User.create({
name: 'Token Test User',
email: 'token@example.com',
password: await bcrypt.hash('password123', 10)
});
userId = user._id;
validToken = jwt.sign(
{ userId: user._id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
});
context('Given a valid token', function() {
it('should allow access to protected routes', async function() {
const response = await request(app)
.get('/api/auth/profile')
.set('Authorization', `Bearer ${validToken}`)
.expect(200);
expect(response.body.data.user).to.have.property('email', 'token@example.com');
});
});
context('Given an invalid token', function() {
it('should reject requests with missing token', async function() {
const response = await request(app)
.get('/api/auth/profile')
.expect(401);
expect(response.body.error).to.match(/token.*required/i);
});
it('should reject requests with malformed token', async function() {
const response = await request(app)
.get('/api/auth/profile')
.set('Authorization', 'Bearer invalid.token.here')
.expect(401);
expect(response.body.error).to.match(/invalid token/i);
});
it('should reject expired tokens', async function() {
const expiredToken = jwt.sign(
{ userId, email: 'token@example.com' },
process.env.JWT_SECRET,
{ expiresIn: '0s' }
);
await new Promise(resolve => setTimeout(resolve, 100));
const response = await request(app)
.get('/api/auth/profile')
.set('Authorization', `Bearer ${expiredToken}`)
.expect(401);
expect(response.body.error).to.match(/token expired/i);
});
});
});
describe('Feature: Password Reset', function() {
beforeEach(async function() {
await User.create({
name: 'Reset User',
email: 'reset@example.com',
password: await bcrypt.hash('oldpassword', 10)
});
});
context('Given a valid reset request', function() {
it('should send password reset email', async function() {
const emailStub = sinon.stub().resolves(true);
// Mock email service
const response = await request(app)
.post('/api/auth/forgot-password')
.send({ email: 'reset@example.com' })
.expect(200);
expect(response.body.message).to.match(/reset link sent/i);
});
it('should generate a reset token', async function() {
await request(app)
.post('/api/auth/forgot-password')
.send({ email: 'reset@example.com' })
.expect(200);
const user = await User.findOne({ email: 'reset@example.com' });
expect(user.resetToken).to.exist;
expect(user.resetTokenExpiry).to.be.above(Date.now());
});
it('should successfully reset password with valid token', async function() {
// Request reset
await request(app)
.post('/api/auth/forgot-password')
.send({ email: 'reset@example.com' });
const user = await User.findOne({ email: 'reset@example.com' });
const resetToken = user.resetToken;
// Reset password
const response = await request(app)
.post('/api/auth/reset-password')
.send({
token: resetToken,
newPassword: 'NewSecurePassword123!'
})
.expect(200);
expect(response.body.message).to.match(/password reset successful/i);
// Verify new password works
const loginResponse = await request(app)
.post('/api/auth/login')
.send({
email: 'reset@example.com',
password: 'NewSecurePassword123!'
})
.expect(200);
expect(loginResponse.body.success).to.be.true;
});
});
context('Given invalid reset scenarios', function() {
it('should reject reset with expired token', async function() {
const user = await User.findOne({ email: 'reset@example.com' });
user.resetToken = 'expired-token';
user.resetTokenExpiry = Date.now() - 1000; // Expired
await user.save();
const response = await request(app)
.post('/api/auth/reset-password')
.send({
token: 'expired-token',
newPassword: 'NewPassword123!'
})
.expect(400);
expect(response.body.error).to.match(/token.*expired/i);
});
it('should not reveal if email exists in forgot password', async function() {
const response = await request(app)
.post('/api/auth/forgot-password')
.send({ email: 'nonexistent@example.com' })
.expect(200);
// Should return success even for non-existent email (security)
expect(response.body.message).to.match(/reset link sent/i);
});
});
});
});
| Feature | Mocha | Jest |
|---|---|---|
| Assertion Library | Choose your own (Chai, Should, Expect) | Built-in assertions |
| Mocking | Sinon (separate library) | Built-in mocking |
| BDD Style | Excellent with describe/context/it | Good with describe/it |
| Flexibility | Highly flexible, modular | Opinionated, batteries-included |
| Setup | Manual configuration | Zero config for most cases |
| Speed | Fast, lightweight | Fast with parallel execution |
| Best For | BDD, backend APIs, flexible setups | React apps, unit tests, simplicity |
Chai offers three assertion styles - all are equally valid:
const { expect } = require('chai');
expect(user).to.exist;
expect(user.email).to.equal('test@example.com');
expect(response.status).to.be.oneOf([200, 201]);
expect(users).to.have.lengthOf(3);
expect(token).to.match(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/);
expect(user.password).to.not.equal(plainPassword);
const should = require('chai').should();
user.should.exist;
user.email.should.equal('test@example.com');
response.status.should.be.oneOf([200, 201]);
users.should.have.lengthOf(3);
token.should.match(/^[A-Za-z0-9-_]+/);
const { assert } = require('chai');
assert.exists(user);
assert.equal(user.email, 'test@example.com');
assert.oneOf(response.status, [200, 201]);
assert.lengthOf(users, 3);
assert.match(token, /^[A-Za-z0-9-_]+/);
const sinon = require('sinon');
const emailService = require('../services/emailService');
describe('Email Notifications', function() {
let emailStub;
beforeEach(function() {
// Create a stub that always resolves successfully
emailStub = sinon.stub(emailService, 'sendEmail').resolves({
messageId: 'mock-id-123',
accepted: ['user@example.com']
});
});
afterEach(function() {
// Restore original function
emailStub.restore();
});
it('should send welcome email on registration', async function() {
await request(app)
.post('/api/auth/register')
.send({ name: 'John', email: 'john@example.com', password: 'pass123' });
expect(emailStub).to.have.been.calledOnce;
expect(emailStub).to.have.been.calledWith(
'john@example.com',
'Welcome to Our App'
);
});
});
const bcrypt = require('bcryptjs');
const hashSpy = sinon.spy(bcrypt, 'hash');
it('should call bcrypt.hash when creating user', async function() {
await request(app)
.post('/api/auth/register')
.send({ name: 'Test', email: 'test@example.com', password: 'pass123' });
expect(hashSpy).to.have.been.called;
expect(hashSpy.firstCall.args[0]).to.equal('pass123');
expect(hashSpy.firstCall.args[1]).to.be.a('number'); // salt rounds
hashSpy.restore();
});
describe('Token Expiration', function() {
let clock;
beforeEach(function() {
clock = sinon.useFakeTimers();
});
afterEach(function() {
clock.restore();
});
it('should expire token after 1 hour', async function() {
const token = generateToken(userId);
// Fast-forward 1 hour and 1 second
clock.tick(3601000);
const response = await request(app)
.get('/api/auth/profile')
.set('Authorization', `Bearer ${token}`)
.expect(401);
expect(response.body.error).to.match(/expired/i);
});
});
{
"require": ["./test/setup.js"],
"spec": "test/**/*.spec.js",
"timeout": 5000,
"slow": 1000,
"reporter": "spec",
"ui": "bdd",
"color": true,
"bail": false,
"exit": true
}
{
"scripts": {
"test": "mocha",
"test:watch": "mocha --watch",
"test:coverage": "nyc mocha",
"test:report": "nyc --reporter=html mocha",
"test:unit": "mocha test/unit/**/*.spec.js",
"test:integration": "mocha test/integration/**/*.spec.js"
},
"devDependencies": {
"mocha": "^10.2.0",
"chai": "^4.3.10",
"sinon": "^17.0.1",
"sinon-chai": "^3.7.0",
"supertest": "^6.3.3",
"nyc": "^15.1.0"
}
}
// test/setup.js
const chai = require('chai');
const sinon = require('sinon');
const sinonChai = require('sinon-chai');
const mongoose = require('mongoose');
// Add sinon assertions to chai
chai.use(sinonChai);
// Global test hooks
before(async function() {
// Connect to test database
await mongoose.connect(process.env.MONGO_TEST_URI);
});
after(async function() {
// Cleanup
await mongoose.connection.dropDatabase();
await mongoose.connection.close();
});
beforeEach(async function() {
// Clear all collections before each test
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
});
// Set test environment variables
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-secret-key';
This project provided deep experience with BDD testing methodology and the Mocha ecosystem: