Module 29: Testing and Test-Driven Development
Testing ensures your code works correctly and continues to work as changes are made. Test-Driven Development (TDD) is a methodology where you write tests before implementing features.
1. Types of Testing
1.1 Unit Testing
Test individual functions or components in isolation.
// Function to test
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
function divide(a, b) {
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
}
// Manual unit tests (basic)
function test(description, fn) {
try {
fn();
console.log(`✓ ${description}`);
} catch (error) {
console.error(`✗ ${description}`);
console.error(error);
}
}
function assert(condition, message) {
if (!condition) {
throw new Error(message || 'Assertion failed');
}
}
function assertEqual(actual, expected, message) {
if (actual !== expected) {
throw new Error(
message || `Expected ${expected} but got ${actual}`
);
}
}
// Tests
test('add() should add two numbers', () => {
assertEqual(add(2, 3), 5);
assertEqual(add(-1, 1), 0);
assertEqual(add(0, 0), 0);
});
test('multiply() should multiply two numbers', () => {
assertEqual(multiply(2, 3), 6);
assertEqual(multiply(-2, 3), -6);
assertEqual(multiply(0, 5), 0);
});
test('divide() should divide two numbers', () => {
assertEqual(divide(6, 2), 3);
assertEqual(divide(5, 2), 2.5);
});
test('divide() should throw error for division by zero', () => {
try {
divide(5, 0);
throw new Error('Expected error was not thrown');
} catch (error) {
assert(error.message === 'Division by zero');
}
});
1.2 Integration Testing
Test how multiple units work together.
// Components to integrate
class UserService {
constructor(database) {
this.database = database;
}
async getUser(id) {
return await this.database.findById('users', id);
}
async createUser(userData) {
return await this.database.insert('users', userData);
}
}
class AuthService {
constructor(userService) {
this.userService = userService;
}
async login(email, password) {
const users = await this.userService.database.find('users', { email });
const user = users[0];
if (!user || user.password !== password) {
throw new Error('Invalid credentials');
}
return { token: 'jwt-token', userId: user.id };
}
}
// Mock database for testing
class MockDatabase {
constructor() {
this.data = {
users: []
};
this.nextId = 1;
}
async findById(collection, id) {
return this.data[collection].find(item => item.id === id);
}
async find(collection, query) {
return this.data[collection].filter(item => {
return Object.keys(query).every(key => item[key] === query[key]);
});
}
async insert(collection, data) {
const item = { id: this.nextId++, ...data };
this.data[collection].push(item);
return item;
}
}
// Integration tests
async function testUserAuth() {
const db = new MockDatabase();
const userService = new UserService(db);
const authService = new AuthService(userService);
// Test: Create user and login
const user = await userService.createUser({
email: 'test@example.com',
password: 'password123'
});
assert(user.id === 1, 'User should have ID 1');
const auth = await authService.login('test@example.com', 'password123');
assert(auth.token, 'Should return auth token');
assert(auth.userId === 1, 'Should return correct user ID');
// Test: Invalid login
try {
await authService.login('test@example.com', 'wrong');
throw new Error('Should have thrown error');
} catch (error) {
assert(error.message === 'Invalid credentials');
}
console.log('✓ Integration tests passed');
}
testUserAuth();
1.3 End-to-End Testing
Test complete user workflows.
// E2E test example (pseudo-code with Playwright/Puppeteer)
async function testCheckoutFlow() {
const browser = await launch();
const page = await browser.newPage();
try {
// Navigate to product page
await page.goto('https://example.com/products/123');
// Add to cart
await page.click('#addToCart');
await page.waitForSelector('.cart-notification');
// Go to cart
await page.click('#viewCart');
await page.waitForSelector('.cart-items');
// Verify item in cart
const items = await page.$$('.cart-item');
assert(items.length === 1, 'Should have 1 item in cart');
// Proceed to checkout
await page.click('#checkout');
await page.waitForSelector('#payment-form');
// Fill payment info
await page.type('#cardNumber', '4111111111111111');
await page.type('#expiry', '12/25');
await page.type('#cvv', '123');
// Submit order
await page.click('#submitOrder');
await page.waitForSelector('.order-confirmation');
// Verify confirmation
const confirmation = await page.$eval('.order-number', el => el.textContent);
assert(confirmation, 'Should display order number');
console.log('✓ E2E checkout test passed');
} finally {
await browser.close();
}
}
Unit tests (many) → Integration tests (some) → E2E tests (few). Unit tests are fast and cheap, E2E tests are slow and expensive.
2. Testing with Jest
2.1 Basic Jest Setup
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"devDependencies": {
"jest": "^29.0.0"
}
}
// math.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export function multiply(a, b) {
return a * b;
}
export function divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
// math.test.js
import { add, subtract, multiply, divide } from './math';
describe('Math operations', () => {
describe('add()', () => {
test('should add two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
test('should add negative numbers', () => {
expect(add(-2, -3)).toBe(-5);
});
test('should handle zero', () => {
expect(add(5, 0)).toBe(5);
expect(add(0, 5)).toBe(5);
});
});
describe('divide()', () => {
test('should divide two numbers', () => {
expect(divide(6, 2)).toBe(3);
expect(divide(5, 2)).toBe(2.5);
});
test('should throw error for division by zero', () => {
expect(() => divide(5, 0)).toThrow('Division by zero');
});
});
});
2.2 Jest Matchers
describe('Jest matchers', () => {
// Equality
test('toBe vs toEqual', () => {
expect(2 + 2).toBe(4);
expect({ name: 'John' }).toEqual({ name: 'John' });
// expect({ name: 'John' }).toBe({ name: 'John' }); // Fails!
});
// Truthiness
test('truthiness', () => {
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(0).toBeDefined();
});
// Numbers
test('numbers', () => {
expect(4).toBeGreaterThan(3);
expect(4).toBeGreaterThanOrEqual(4);
expect(3).toBeLessThan(4);
expect(0.1 + 0.2).toBeCloseTo(0.3); // Floating point
});
// Strings
test('strings', () => {
expect('hello world').toMatch(/world/);
expect('hello').toContain('ell');
});
// Arrays and iterables
test('arrays', () => {
const fruits = ['apple', 'banana', 'orange'];
expect(fruits).toContain('banana');
expect(fruits).toHaveLength(3);
expect(new Set([1, 2, 3])).toContain(2);
});
// Objects
test('objects', () => {
const user = { name: 'John', age: 30 };
expect(user).toMatchObject({ name: 'John' });
expect(user).toHaveProperty('age');
expect(user).toHaveProperty('age', 30);
});
// Exceptions
test('exceptions', () => {
const throwError = () => {
throw new Error('Oops!');
};
expect(throwError).toThrow();
expect(throwError).toThrow('Oops!');
expect(throwError).toThrow(Error);
});
// Async
test('async with promises', () => {
return fetchData().then(data => {
expect(data).toBe('data');
});
});
test('async with async/await', async () => {
const data = await fetchData();
expect(data).toBe('data');
});
test('async rejection', async () => {
await expect(fetchDataFails()).rejects.toThrow('error');
});
});
2.3 Mocking
// user.js
export class UserService {
constructor(apiClient) {
this.apiClient = apiClient;
}
async getUser(id) {
const response = await this.apiClient.get(`/users/${id}`);
return response.data;
}
async createUser(userData) {
const response = await this.apiClient.post('/users', userData);
return response.data;
}
}
// user.test.js
import { UserService } from './user';
describe('UserService', () => {
let userService;
let mockApiClient;
beforeEach(() => {
// Create mock
mockApiClient = {
get: jest.fn(),
post: jest.fn()
};
userService = new UserService(mockApiClient);
});
test('getUser() should fetch user by id', async () => {
const mockUser = { id: 1, name: 'John' };
mockApiClient.get.mockResolvedValue({ data: mockUser });
const user = await userService.getUser(1);
expect(mockApiClient.get).toHaveBeenCalledWith('/users/1');
expect(mockApiClient.get).toHaveBeenCalledTimes(1);
expect(user).toEqual(mockUser);
});
test('createUser() should create new user', async () => {
const userData = { name: 'Jane', email: 'jane@example.com' };
const mockResponse = { id: 2, ...userData };
mockApiClient.post.mockResolvedValue({ data: mockResponse });
const user = await userService.createUser(userData);
expect(mockApiClient.post).toHaveBeenCalledWith('/users', userData);
expect(user).toEqual(mockResponse);
});
test('getUser() should handle errors', async () => {
mockApiClient.get.mockRejectedValue(new Error('Network error'));
await expect(userService.getUser(1)).rejects.toThrow('Network error');
});
});
// Spying on methods
describe('Spying', () => {
test('spy on console.log', () => {
const spy = jest.spyOn(console, 'log');
console.log('Hello', 'World');
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith('Hello', 'World');
spy.mockRestore(); // Restore original implementation
});
test('spy on object method', () => {
const calculator = {
add: (a, b) => a + b
};
const spy = jest.spyOn(calculator, 'add');
calculator.add(2, 3);
expect(spy).toHaveBeenCalledWith(2, 3);
expect(spy).toHaveReturnedWith(5);
});
});
// Mock modules
jest.mock('./api', () => ({
fetchUser: jest.fn(),
createUser: jest.fn()
}));
import { fetchUser, createUser } from './api';
test('mocked module', async () => {
fetchUser.mockResolvedValue({ id: 1, name: 'John' });
const user = await fetchUser(1);
expect(user.name).toBe('John');
});
Mock: Replace entire function/module. Spy: Watch original function and track calls while keeping original implementation.
3. Test-Driven Development (TDD)
3.1 TDD Cycle: Red-Green-Refactor
// Step 1: Write failing test (RED)
// calculator.test.js
import { Calculator } from './calculator';
describe('Calculator', () => {
test('should add two numbers', () => {
const calc = new Calculator();
expect(calc.add(2, 3)).toBe(5);
});
});
// Running test: FAIL (calculator.js doesn't exist yet)
// Step 2: Write minimal code to pass (GREEN)
// calculator.js
export class Calculator {
add(a, b) {
return a + b;
}
}
// Running test: PASS
// Step 3: Refactor (REFACTOR)
// No refactoring needed for this simple case
// Continue with more tests...
describe('Calculator', () => {
let calc;
beforeEach(() => {
calc = new Calculator();
});
test('should subtract two numbers', () => {
expect(calc.subtract(5, 3)).toBe(2);
});
test('should multiply two numbers', () => {
expect(calc.multiply(2, 3)).toBe(6);
});
test('should divide two numbers', () => {
expect(calc.divide(6, 2)).toBe(3);
});
test('should throw error for division by zero', () => {
expect(() => calc.divide(5, 0)).toThrow();
});
});
// Implement remaining methods
export class Calculator {
add(a, b) {
return a + b;
}
subtract(a, b) {
return a - b;
}
multiply(a, b) {
return a * b;
}
divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
}
3.2 TDD Example: String Calculator
// Following TDD strictly
// Test 1: Empty string returns 0
test('empty string returns 0', () => {
expect(stringCalculator('')).toBe(0);
});
// Implementation
function stringCalculator(input) {
return 0;
}
// Test 2: Single number returns that number
test('single number returns itself', () => {
expect(stringCalculator('5')).toBe(5);
});
// Implementation
function stringCalculator(input) {
if (input === '') return 0;
return parseInt(input);
}
// Test 3: Two numbers separated by comma
test('two numbers separated by comma', () => {
expect(stringCalculator('1,2')).toBe(3);
});
// Implementation
function stringCalculator(input) {
if (input === '') return 0;
const numbers = input.split(',').map(Number);
return numbers.reduce((sum, n) => sum + n, 0);
}
// Test 4: Multiple numbers
test('multiple numbers', () => {
expect(stringCalculator('1,2,3,4')).toBe(10);
});
// Implementation: Already works!
// Test 5: Handle newlines
test('handle newlines as delimiters', () => {
expect(stringCalculator('1\n2,3')).toBe(6);
});
// Implementation
function stringCalculator(input) {
if (input === '') return 0;
const numbers = input.split(/[,\n]/).map(Number);
return numbers.reduce((sum, n) => sum + n, 0);
}
// Test 6: Negative numbers throw error
test('negative numbers throw error', () => {
expect(() => stringCalculator('1,-2,3')).toThrow('Negatives not allowed: -2');
});
// Implementation
function stringCalculator(input) {
if (input === '') return 0;
const numbers = input.split(/[,\n]/).map(Number);
const negatives = numbers.filter(n => n < 0);
if (negatives.length > 0) {
throw new Error(`Negatives not allowed: ${negatives.join(', ')}`);
}
return numbers.reduce((sum, n) => sum + n, 0);
}
// Test 7: Ignore numbers greater than 1000
test('ignore numbers greater than 1000', () => {
expect(stringCalculator('2,1001')).toBe(2);
expect(stringCalculator('2,1000')).toBe(1002);
});
// Final implementation
function stringCalculator(input) {
if (input === '') return 0;
const numbers = input.split(/[,\n]/).map(Number);
const negatives = numbers.filter(n => n < 0);
if (negatives.length > 0) {
throw new Error(`Negatives not allowed: ${negatives.join(', ')}`);
}
return numbers
.filter(n => n <= 1000)
.reduce((sum, n) => sum + n, 0);
}
- Better design: Writing tests first forces you to think about API design
- Confidence: Every feature is tested from the start
- Documentation: Tests serve as usage examples
- Regression prevention: Catch bugs early
4. Testing Best Practices
4.1 AAA Pattern (Arrange-Act-Assert)
describe('ShoppingCart', () => {
test('should add item to cart', () => {
// Arrange: Set up test data and dependencies
const cart = new ShoppingCart();
const item = { id: 1, name: 'Laptop', price: 999 };
// Act: Execute the function being tested
cart.addItem(item);
// Assert: Verify the result
expect(cart.getItems()).toHaveLength(1);
expect(cart.getItems()[0]).toEqual(item);
expect(cart.getTotal()).toBe(999);
});
});
4.2 Test Organization
describe('UserService', () => {
// Setup and teardown
let userService;
let mockDb;
beforeAll(() => {
// Runs once before all tests in this describe block
console.log('Starting UserService tests');
});
beforeEach(() => {
// Runs before each test
mockDb = new MockDatabase();
userService = new UserService(mockDb);
});
afterEach(() => {
// Runs after each test
mockDb.clear();
});
afterAll(() => {
// Runs once after all tests in this describe block
console.log('Finished UserService tests');
});
describe('getUser()', () => {
test('should return user when found', async () => {
// Test implementation
});
test('should return null when not found', async () => {
// Test implementation
});
});
describe('createUser()', () => {
test('should create user with valid data', async () => {
// Test implementation
});
test('should throw error with invalid data', async () => {
// Test implementation
});
});
});
4.3 Testing Edge Cases
describe('Array utility functions', () => {
describe('findMax()', () => {
test('should find max in positive numbers', () => {
expect(findMax([1, 5, 3, 9, 2])).toBe(9);
});
test('should handle negative numbers', () => {
expect(findMax([-5, -2, -10])).toBe(-2);
});
test('should handle single element', () => {
expect(findMax([42])).toBe(42);
});
test('should throw error for empty array', () => {
expect(() => findMax([])).toThrow('Empty array');
});
test('should handle duplicates', () => {
expect(findMax([5, 5, 5])).toBe(5);
});
test('should handle floating point numbers', () => {
expect(findMax([1.1, 2.2, 1.9])).toBe(2.2);
});
test('should handle Infinity', () => {
expect(findMax([1, Infinity, 100])).toBe(Infinity);
});
});
});
4.4 Test Naming Conventions
// ✅ Good: Descriptive test names
describe('User authentication', () => {
test('should return token when credentials are valid', () => {});
test('should throw error when email is invalid', () => {});
test('should throw error when password is incorrect', () => {});
test('should lock account after 5 failed attempts', () => {});
});
// ❌ Bad: Vague test names
describe('Auth', () => {
test('test1', () => {});
test('works', () => {});
test('error case', () => {});
});
// Alternative: Given-When-Then format
describe('Shopping cart checkout', () => {
test('given empty cart, when checkout is called, then error is thrown', () => {});
test('given valid items, when checkout succeeds, then order is created', () => {});
});
Test behavior, not implementation. Tests should not break when you refactor code without changing functionality.
5. Code Coverage
5.1 Understanding Coverage
// Run with: jest --coverage
// calculator.js
export class Calculator {
add(a, b) {
return a + b;
}
subtract(a, b) {
return a - b;
}
multiply(a, b) {
return a * b;
}
divide(a, b) {
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
}
power(base, exponent) {
if (exponent === 0) return 1;
if (exponent < 0) return 1 / this.power(base, -exponent);
return base * this.power(base, exponent - 1);
}
}
// calculator.test.js - Partial coverage
describe('Calculator', () => {
let calc;
beforeEach(() => {
calc = new Calculator();
});
test('should add numbers', () => {
expect(calc.add(2, 3)).toBe(5);
});
test('should multiply numbers', () => {
expect(calc.multiply(2, 3)).toBe(6);
});
test('should divide numbers', () => {
expect(calc.divide(6, 2)).toBe(3);
});
// Missing tests for:
// - subtract()
// - divide by zero
// - power()
// - power with negative exponent
});
// Coverage report shows:
// - Line coverage: 60%
// - Branch coverage: 40%
// - Function coverage: 67%
5.2 Improving Coverage
// Complete test suite
describe('Calculator', () => {
let calc;
beforeEach(() => {
calc = new Calculator();
});
describe('add()', () => {
test('should add positive numbers', () => {
expect(calc.add(2, 3)).toBe(5);
});
test('should add negative numbers', () => {
expect(calc.add(-2, -3)).toBe(-5);
});
});
describe('subtract()', () => {
test('should subtract numbers', () => {
expect(calc.subtract(5, 3)).toBe(2);
});
});
describe('multiply()', () => {
test('should multiply numbers', () => {
expect(calc.multiply(2, 3)).toBe(6);
});
test('should handle zero', () => {
expect(calc.multiply(5, 0)).toBe(0);
});
});
describe('divide()', () => {
test('should divide numbers', () => {
expect(calc.divide(6, 2)).toBe(3);
});
test('should throw error for division by zero', () => {
expect(() => calc.divide(5, 0)).toThrow('Division by zero');
});
});
describe('power()', () => {
test('should calculate power of positive exponent', () => {
expect(calc.power(2, 3)).toBe(8);
});
test('should return 1 for exponent 0', () => {
expect(calc.power(5, 0)).toBe(1);
});
test('should handle negative exponent', () => {
expect(calc.power(2, -2)).toBe(0.25);
});
});
});
// Now coverage shows:
// - Line coverage: 100%
// - Branch coverage: 100%
// - Function coverage: 100%
Aim for 80-100% coverage, but remember: 100% coverage doesn't mean bug-free code. Focus on testing meaningful scenarios.
6. Snapshot Testing
6.1 Component Snapshots (React example concept)
// Button.js
export function Button({ children, onClick, variant = 'primary' }) {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
>
{children}
</button>
);
}
// Button.test.js
import { render } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
test('should match snapshot for primary variant', () => {
const { container } = render(<Button>Click me</Button>);
expect(container.firstChild).toMatchSnapshot();
});
test('should match snapshot for secondary variant', () => {
const { container } = render(
<Button variant="secondary">Cancel</Button>
);
expect(container.firstChild).toMatchSnapshot();
});
});
// Generates __snapshots__/Button.test.js.snap:
/*
exports[`Button should match snapshot for primary variant 1`] = `
<button class="btn btn-primary">
Click me
</button>
`;
*/
6.2 Data Snapshots
// formatUser.js
export function formatUser(user) {
return {
displayName: `${user.firstName} ${user.lastName}`,
email: user.email.toLowerCase(),
age: user.age,
memberSince: new Date(user.joinDate).getFullYear(),
status: user.isActive ? 'active' : 'inactive'
};
}
// formatUser.test.js
import { formatUser } from './formatUser';
describe('formatUser()', () => {
test('should format user data correctly', () => {
const user = {
firstName: 'John',
lastName: 'Doe',
email: 'JOHN@EXAMPLE.COM',
age: 30,
joinDate: '2020-01-15',
isActive: true
};
expect(formatUser(user)).toMatchSnapshot();
});
});
// Generates snapshot:
/*
exports[`formatUser() should format user data correctly 1`] = `
Object {
"age": 30,
"displayName": "John Doe",
"email": "john@example.com",
"memberSince": 2020,
"status": "active",
}
`;
*/
Summary
In this module, you learned:
- ✅ Types of testing: Unit, Integration, E2E
- ✅ Jest testing framework and matchers
- ✅ Mocking and spying techniques
- ✅ Test-Driven Development (TDD) methodology
- ✅ Red-Green-Refactor cycle
- ✅ Testing best practices (AAA pattern, naming, organization)
- ✅ Code coverage and its importance
- ✅ Snapshot testing
- ✅ Edge case testing
In Module 30, you'll learn about Build Tools and Module Bundlers, understanding Webpack, Vite, and modern build processes.
Practice Exercises
- Write unit tests for a todo list application
- Implement TDD for a shopping cart with discount logic
- Create integration tests for user authentication flow
- Write tests for an async API client with error handling
- Achieve 100% code coverage for a utility library
- Test a form validation system with edge cases
- Write snapshot tests for UI components
- Implement a test suite for a calculator with advanced operations