Skip to main content

JavaScript Test Driven Development

Introduction

Test Driven Development (TDD) is a software development methodology that focuses on creating tests before writing the actual code. This approach might seem counterintuitive at first, but it has proven to be highly effective in producing robust, bug-free, and maintainable code.

In this tutorial, we'll explore how to implement TDD in JavaScript projects. You'll learn the fundamental principles, the TDD cycle, and see practical examples using popular testing frameworks. By the end, you'll be equipped with the knowledge to apply TDD to your JavaScript projects.

What is Test Driven Development?

Test Driven Development follows a simple, repeating cycle often referred to as "Red-Green-Refactor":

  1. Red: Write a failing test that defines a function or improvements of a function
  2. Green: Write the minimum amount of code necessary to pass the test
  3. Refactor: Optimize the code without changing its behavior or breaking the tests

The main benefits of TDD include:

  • Improved code quality: By focusing on requirements first, you write more purposeful code
  • Better design: TDD encourages modular, loosely coupled code
  • Built-in documentation: Tests serve as living documentation of how your code should work
  • Fewer bugs: By testing each component thoroughly, fewer bugs make it to production
  • Confidence in refactoring: Tests act as a safety net when making changes

Setting Up Your TDD Environment

Before diving into examples, let's set up a basic JavaScript project with testing capabilities. We'll use Jest, one of the most popular testing frameworks for JavaScript.

Project Setup

bash
# Create a new directory
mkdir js-tdd-demo
cd js-tdd-demo

# Initialize npm
npm init -y

# Install Jest
npm install --save-dev jest

Update your package.json file to use Jest as the test runner:

json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watchAll"
}
}

The TDD Process in Action

Let's implement a simple calculator module using TDD. We'll build this step by step following the Red-Green-Refactor cycle.

Step 1: Write a Failing Test (Red)

First, create a test file calculator.test.js:

javascript
// calculator.test.js
const calculator = require('./calculator');

describe('Calculator', () => {
test('should add two numbers correctly', () => {
expect(calculator.add(1, 2)).toBe(3);
});
});

When we run the test with npm test, it fails because we haven't created the calculator module yet:

FAIL  ./calculator.test.js
● Test suite failed to run

Cannot find module './calculator' from 'calculator.test.js'

Step 2: Write the Minimum Code to Pass the Test (Green)

Now, let's create the calculator.js file with just enough code to make the test pass:

javascript
// calculator.js
const calculator = {
add: (a, b) => a + b
};

module.exports = calculator;

Running npm test again shows the test passing:

PASS  ./calculator.test.js
Calculator
✓ should add two numbers correctly (3ms)

Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total

Step 3: Refactor if Necessary

Our code is already simple, but in real-world scenarios, this is where you would improve code structure, performance, or readability without changing functionality.

Expanding Functionality with TDD

Let's add more functionality to our calculator by adding tests first:

javascript
// calculator.test.js
const calculator = require('./calculator');

describe('Calculator', () => {
test('should add two numbers correctly', () => {
expect(calculator.add(1, 2)).toBe(3);
expect(calculator.add(-1, 1)).toBe(0);
expect(calculator.add(0, 0)).toBe(0);
});

test('should subtract two numbers correctly', () => {
expect(calculator.subtract(5, 2)).toBe(3);
expect(calculator.subtract(2, 5)).toBe(-3);
expect(calculator.subtract(0, 0)).toBe(0);
});

test('should multiply two numbers correctly', () => {
expect(calculator.multiply(2, 3)).toBe(6);
expect(calculator.multiply(-2, 3)).toBe(-6);
expect(calculator.multiply(0, 5)).toBe(0);
});

test('should divide two numbers correctly', () => {
expect(calculator.divide(6, 2)).toBe(3);
expect(calculator.divide(5, 2)).toBe(2.5);
expect(() => calculator.divide(5, 0)).toThrow('Division by zero');
});
});

Now we can implement each function to make the tests pass:

javascript
// calculator.js
const calculator = {
add: (a, b) => a + b,

subtract: (a, b) => a - b,

multiply: (a, b) => a * b,

divide: (a, b) => {
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
}
};

module.exports = calculator;

Real-World TDD Example: Building a User Authentication Validator

Let's create a more practical example: a validator for user registration that checks if email and password meet certain criteria.

Step 1: Write the Tests First

javascript
// userValidator.test.js
const userValidator = require('./userValidator');

describe('User Validator', () => {
describe('email validation', () => {
test('should validate correct email formats', () => {
expect(userValidator.isValidEmail('[email protected]')).toBe(true);
expect(userValidator.isValidEmail('[email protected]')).toBe(true);
});

test('should reject invalid email formats', () => {
expect(userValidator.isValidEmail('')).toBe(false);
expect(userValidator.isValidEmail('user@')).toBe(false);
expect(userValidator.isValidEmail('user@domain')).toBe(false);
expect(userValidator.isValidEmail('@domain.com')).toBe(false);
});
});

describe('password validation', () => {
test('should validate strong passwords', () => {
expect(userValidator.isStrongPassword('P@ssw0rd123')).toBe(true);
expect(userValidator.isStrongPassword('Str0ng!P@ss')).toBe(true);
});

test('should reject weak passwords', () => {
expect(userValidator.isStrongPassword('password')).toBe(false); // no uppercase/numbers
expect(userValidator.isStrongPassword('Pass123')).toBe(false); // too short
expect(userValidator.isStrongPassword('PASSWORD123')).toBe(false); // no lowercase
expect(userValidator.isStrongPassword('Password')).toBe(false); // no numbers/special chars
});
});
});

Step 2: Implement the Validation Logic

javascript
// userValidator.js
const userValidator = {
isValidEmail(email) {
if (!email) return false;

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
},

isStrongPassword(password) {
// Password should be at least 8 characters
if (!password || password.length < 8) return false;

// Check for at least one uppercase letter
if (!/[A-Z]/.test(password)) return false;

// Check for at least one lowercase letter
if (!/[a-z]/.test(password)) return false;

// Check for at least one number
if (!/\d/.test(password)) return false;

// Check for at least one special character
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) return false;

return true;
}
};

module.exports = userValidator;

Testing Asynchronous Code with TDD

Modern JavaScript often involves asynchronous operations. Let's see how to apply TDD to async functions.

Step 1: Write Tests for an Async User Service

javascript
// userService.test.js
const userService = require('./userService');

describe('User Service', () => {
test('should fetch user by ID', async () => {
const user = await userService.getUserById(1);
expect(user).toEqual({
id: 1,
name: 'John Doe',
email: '[email protected]'
});
});

test('should throw error for non-existent user', async () => {
await expect(userService.getUserById(999)).rejects.toThrow('User not found');
});

test('should create a new user', async () => {
const newUser = {
name: 'Jane Smith',
email: '[email protected]'
};

const createdUser = await userService.createUser(newUser);

expect(createdUser).toEqual({
id: expect.any(Number),
name: 'Jane Smith',
email: '[email protected]'
});
});
});

Step 2: Implement the User Service

javascript
// userService.js
// This simulates a database with a simple in-memory array
const users = [
{ id: 1, name: 'John Doe', email: '[email protected]' }
];

const userService = {
async getUserById(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const user = users.find(user => user.id === id);

if (user) {
resolve({ ...user });
} else {
reject(new Error('User not found'));
}
}, 100);
});
},

async createUser(userData) {
return new Promise((resolve) => {
setTimeout(() => {
const newUser = {
id: users.length + 1,
...userData
};

users.push(newUser);
resolve({ ...newUser });
}, 100);
});
}
};

module.exports = userService;

TDD Best Practices

  1. Keep tests small and focused: Each test should verify one specific behavior
  2. Use descriptive test names: Tests should read like documentation
  3. Test behavior, not implementation: Focus on what, not how
  4. Maintain test isolation: Tests shouldn't depend on each other
  5. Follow the AAA pattern: Arrange, Act, Assert
  6. Start simple: Begin with the simplest test case and build complexity gradually
  7. Refactor regularly: Clean code is as important for tests as for production code
  8. Don't skip the red phase: Always see your test fail first to validate that it's testing the right thing

Common TDD Frameworks for JavaScript

  • Jest: Full-featured, zero-config, with built-in assertion library and mocking
  • Mocha: Flexible, requires separate assertion libraries like Chai
  • Jasmine: BDD-style testing with built-in assertions
  • Tape: Minimal, TAP-producing test harness
  • AVA: Minimalist and fast with concurrent test execution

Summary

Test Driven Development is a powerful methodology that can significantly improve your JavaScript code. By writing tests first, you clarify requirements, design better interfaces, and create more maintainable software. The Red-Green-Refactor cycle provides a structured approach that leads to higher quality code with fewer defects.

Remember that TDD is a skill that takes practice to master. Start with simple projects and gradually apply it to more complex scenarios. Over time, you'll find that writing tests first becomes second nature and helps you write more robust JavaScript applications.

Additional Resources

Exercises

  1. Create a string utility library using TDD that includes functions for:

    • Capitalizing the first letter of a string
    • Checking if a string is a palindrome
    • Truncating a string with an ellipsis after a certain length
  2. Build a shopping cart module that can:

    • Add items to the cart
    • Remove items from the cart
    • Calculate the total price
    • Apply discount codes
  3. Implement a simple task manager that can:

    • Add tasks with priority levels
    • Mark tasks as complete
    • Filter tasks by status or priority
    • Sort tasks by due date

Start by writing tests for each feature before implementing the code!



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