The Safety Net
Imagine you're a trapeze artist performing high above the ground. Would you feel comfortable doing your tricks without a safety net? Of course not! In software development, tests are our safety net. They catch us when we fall (make mistakes) and give us confidence to perform more daring feats (write complex code).
Why Jest?
Jest is a delightful JavaScript testing framework created by Facebook. It's like having a Swiss Army knife for testing - it comes with everything you need right out of the box!
Key Features of Jest
- Zero Configuration: Works out of the box for most JavaScript projects
- Fast & Parallel: Tests run in parallel for maximum performance
- Snapshot Testing: Capture snapshots of React trees or other serializable values
- Built-in Mocking: Powerful mocking library included
- Code Coverage: Built-in coverage reports
- Watch Mode: Intelligently runs only affected tests
- Great Error Messages: Clear, helpful error messages and diffs
Setting Up Jest
Let's get Jest up and running in your project:
# Install Jest as a dev dependency
npm install --save-dev jest
# For TypeScript support
npm install --save-dev @types/jest
# For React testing
npm install --save-dev @testing-library/react @testing-library/jest-dom
Package.json Configuration
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"testEnvironment": "jsdom",
"setupFilesAfterEnv": ["<rootDir>/src/setupTests.js"],
"moduleNameMapper": {
"\\.(css|less|scss|sass)$": "identity-obj-proxy"
}
}
}
Jest Configuration File
// jest.config.js
module.exports = {
// Test environment
testEnvironment: 'jsdom', // or 'node' for Node.js projects
// Setup files
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
// Module paths
moduleDirectories: ['node_modules', 'src'],
// File extensions for modules
moduleFileExtensions: ['js', 'jsx', 'json'],
// Transform files with babel-jest
transform: {
'^.+\\.(js|jsx)$': 'babel-jest',
},
// Module name mapping for imports
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
// Coverage configuration
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/index.js',
'!src/serviceWorker.js',
],
// Test match patterns
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{js,jsx}',
'<rootDir>/src/**/*.{spec,test}.{js,jsx}',
],
// Watch plugins
watchPlugins: [
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname',
],
};
Your First Jest Test
Let's write our first test. Think of a test as a conversation with your code:
// 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('Cannot divide by zero');
}
return a / b;
}
// math.test.js
import { add, subtract, multiply, divide } from './math';
describe('Math functions', () => {
// Test suite for add function
describe('add', () => {
test('adds 1 + 2 to equal 3', () => {
expect(add(1, 2)).toBe(3);
});
test('adds negative numbers correctly', () => {
expect(add(-1, -1)).toBe(-2);
});
});
// Test suite for subtract function
describe('subtract', () => {
test('subtracts 5 - 3 to equal 2', () => {
expect(subtract(5, 3)).toBe(2);
});
});
// Test suite for multiply function
describe('multiply', () => {
test('multiplies 2 * 3 to equal 6', () => {
expect(multiply(2, 3)).toBe(6);
});
});
// Test suite for divide function
describe('divide', () => {
test('divides 6 / 2 to equal 3', () => {
expect(divide(6, 2)).toBe(3);
});
test('throws error when dividing by zero', () => {
expect(() => divide(5, 0)).toThrow('Cannot divide by zero');
});
});
});
Understanding Test Structure
Jest tests follow a clear structure, like chapters in a book:
The AAA Pattern
test('user can update their profile', () => {
// Arrange - Set up test data and conditions
const user = {
id: 1,
name: 'John Doe',
email: 'john@example.com'
};
// Act - Execute the code being tested
const updatedUser = updateProfile(user, { name: 'John Smith' });
// Assert - Check the results
expect(updatedUser.name).toBe('John Smith');
expect(updatedUser.email).toBe('john@example.com');
});
Jest Matchers
Matchers are like different lenses through which you examine your code's output. Jest provides a rich set of matchers:
Common Matchers
// Exact equality
expect(2 + 2).toBe(4);
// Object equality
expect({ name: 'John' }).toEqual({ name: 'John' });
// Truthiness
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(1).toBeDefined();
// Numbers
expect(10).toBeGreaterThan(5);
expect(5).toBeLessThan(10);
expect(5.5).toBeCloseTo(5.5, 2);
// Strings
expect('Hello World').toMatch(/World/);
expect('team').not.toMatch(/I/);
// Arrays and iterables
expect(['Apple', 'Banana', 'Orange']).toContain('Banana');
expect(new Set(['Apple', 'Banana'])).toContain('Apple');
// Exceptions
expect(() => {
throw new Error('Invalid input');
}).toThrow('Invalid input');
// Async code
await expect(fetchData()).resolves.toBe('data');
await expect(fetchError()).rejects.toThrow('error');
Custom Matchers
// Create a custom matcher
expect.extend({
toBeWithinRange(received, floor, ceiling) {
const pass = received >= floor && received <= ceiling;
if (pass) {
return {
message: () => `expected ${received} not to be within range ${floor} - ${ceiling}`,
pass: true,
};
} else {
return {
message: () => `expected ${received} to be within range ${floor} - ${ceiling}`,
pass: false,
};
}
},
});
// Use the custom matcher
test('numeric ranges', () => {
expect(100).toBeWithinRange(90, 110);
expect(101).not.toBeWithinRange(0, 100);
});
Testing Asynchronous Code
Testing async code is like waiting for a pizza delivery - you need to handle the waiting period properly:
Promises
// Function that returns a promise
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id === 1) {
resolve({ id: 1, name: 'John Doe' });
} else {
reject(new Error('User not found'));
}
}, 100);
});
}
// Testing with promises
test('fetches user successfully', () => {
return fetchUser(1).then(user => {
expect(user.name).toBe('John Doe');
});
});
// Using .resolves/.rejects
test('fetches user successfully with resolves', () => {
return expect(fetchUser(1)).resolves.toEqual({
id: 1,
name: 'John Doe'
});
});
test('fails to fetch non-existent user', () => {
return expect(fetchUser(999)).rejects.toThrow('User not found');
});
Async/Await
// Testing with async/await
test('fetches user with async/await', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('John Doe');
});
test('handles errors with async/await', async () => {
expect.assertions(1);
try {
await fetchUser(999);
} catch (error) {
expect(error.message).toBe('User not found');
}
});
// Cleaner error handling
test('handles errors with async/await and expect', async () => {
await expect(fetchUser(999)).rejects.toThrow('User not found');
});
Callbacks
// Function with callback
function fetchUserCallback(id, callback) {
setTimeout(() => {
if (id === 1) {
callback(null, { id: 1, name: 'John Doe' });
} else {
callback(new Error('User not found'));
}
}, 100);
}
// Testing callbacks with done
test('fetches user with callback', done => {
function callback(error, user) {
if (error) {
done(error);
return;
}
try {
expect(user.name).toBe('John Doe');
done();
} catch (error) {
done(error);
}
}
fetchUserCallback(1, callback);
});
Setup and Teardown
Like preparing a kitchen before cooking and cleaning up afterward, Jest provides hooks for setup and teardown:
describe('Database operations', () => {
let db;
// Run once before all tests in this describe block
beforeAll(async () => {
db = await createDatabaseConnection();
});
// Run once after all tests in this describe block
afterAll(async () => {
await db.close();
});
// Run before each test
beforeEach(async () => {
await db.clear();
await db.seed();
});
// Run after each test
afterEach(async () => {
await db.cleanup();
});
test('creates a new user', async () => {
const user = await db.createUser({ name: 'John' });
expect(user.id).toBeDefined();
});
test('finds existing user', async () => {
const user = await db.findUser(1);
expect(user).toBeTruthy();
});
});
// Scoped setup
describe('User service', () => {
describe('when user is logged in', () => {
beforeEach(() => {
// Setup for logged in state
mockAuth.login({ id: 1, role: 'user' });
});
test('can access profile', () => {
// Test with logged in user
});
});
describe('when user is admin', () => {
beforeEach(() => {
// Setup for admin state
mockAuth.login({ id: 1, role: 'admin' });
});
test('can access admin panel', () => {
// Test with admin user
});
});
});
Mocking in Jest
Mocking is like using stunt doubles in movies - we replace real dependencies with controlled substitutes:
Function Mocks
// Creating mock functions
const mockCallback = jest.fn();
// Using the mock
mockCallback('first call');
mockCallback('second call');
// Assertions on mock calls
expect(mockCallback).toHaveBeenCalled();
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith('first call');
// Mock implementation
const mockAdd = jest.fn((a, b) => a + b);
expect(mockAdd(1, 2)).toBe(3);
// Mock return values
const mockRandom = jest.fn();
mockRandom
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.5)
.mockReturnValue(0.9);
console.log(mockRandom()); // 0.1
console.log(mockRandom()); // 0.5
console.log(mockRandom()); // 0.9
console.log(mockRandom()); // 0.9
// Mock resolved/rejected values
const mockFetch = jest.fn();
mockFetch.mockResolvedValue({ data: 'test' });
mockFetch.mockRejectedValue(new Error('Network error'));
Module Mocks
// api.js
export const fetchData = () => {
return fetch('/api/data').then(res => res.json());
};
// userService.js
import { fetchData } from './api';
export const getUserData = async (userId) => {
const data = await fetchData();
return data.users.find(user => user.id === userId);
};
// userService.test.js
import { getUserData } from './userService';
import { fetchData } from './api';
// Mock the entire module
jest.mock('./api');
test('gets user data', async () => {
// Mock the implementation
fetchData.mockResolvedValue({
users: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
});
const user = await getUserData(1);
expect(user.name).toBe('John');
expect(fetchData).toHaveBeenCalled();
});
Partial Mocks
// Mock only specific exports
jest.mock('./utils', () => ({
...jest.requireActual('./utils'), // Keep all original exports
calculateTotal: jest.fn(), // Mock only this function
}));
// Spy on existing methods
const math = require('./math');
const addSpy = jest.spyOn(math, 'add');
// Now math.add is being tracked
math.add(1, 2);
expect(addSpy).toHaveBeenCalledWith(1, 2);
// Restore original implementation
addSpy.mockRestore();
Testing Timers
When testing code with timers, Jest lets you control time like a time machine:
// Function with timer
function delayedGreeting(callback) {
setTimeout(() => {
callback('Hello after 1 second');
}, 1000);
}
// Traditional approach (slow)
test('calls callback after 1 second', (done) => {
delayedGreeting((greeting) => {
expect(greeting).toBe('Hello after 1 second');
done();
});
});
// Using fake timers (fast)
test('calls callback after 1 second with fake timers', () => {
jest.useFakeTimers();
const callback = jest.fn();
delayedGreeting(callback);
// Fast-forward time
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledWith('Hello after 1 second');
jest.useRealTimers(); // Restore real timers
});
// Testing with intervals
function repeatGreeting(callback) {
setInterval(() => {
callback('Hello');
}, 1000);
}
test('calls callback repeatedly', () => {
jest.useFakeTimers();
const callback = jest.fn();
repeatGreeting(callback);
// Fast-forward by 3 seconds
jest.advanceTimersByTime(3000);
expect(callback).toHaveBeenCalledTimes(3);
jest.useRealTimers();
});
// Run all timers
test('runs all timers', () => {
jest.useFakeTimers();
const callback = jest.fn();
setTimeout(callback, 1000);
setTimeout(callback, 2000);
jest.runAllTimers();
expect(callback).toHaveBeenCalledTimes(2);
});
Snapshot Testing
Snapshot testing is like taking a photo of your UI and comparing it with previous photos to detect changes:
// Button.js
import React from 'react';
export const Button = ({ label, onClick, type = 'button' }) => (
<button
className={`btn btn-${type}`}
onClick={onClick}
>
{label}
</button>
);
// Button.test.js
import React from 'react';
import renderer from 'react-test-renderer';
import { Button } from './Button';
test('Button renders correctly', () => {
const tree = renderer
.create(<Button label="Click me" type="primary" />)
.toJSON();
expect(tree).toMatchSnapshot();
});
// Generated snapshot file (Button.test.js.snap)
// Jest creates this automatically
exports[`Button renders correctly 1`] = `
<button
className="btn btn-primary"
onClick={undefined}
>
Click me
</button>
`;
// Inline snapshots
test('Button renders with inline snapshot', () => {
const tree = renderer
.create(<Button label="Submit" type="submit" />)
.toJSON();
expect(tree).toMatchInlineSnapshot(`
<button
className="btn btn-submit"
onClick={undefined}
>
Submit
</button>
`);
});
// Property matchers for dynamic content
test('Error message with timestamp', () => {
const error = {
message: 'Network error',
timestamp: Date.now(),
id: Math.random()
};
expect(error).toMatchSnapshot({
timestamp: expect.any(Number),
id: expect.any(Number)
});
});
Code Coverage
Code coverage is like a map showing which parts of your code have been tested:
# Run tests with coverage
npm test -- --coverage
# Coverage report output
--------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------------|---------|----------|---------|---------|-------------------
All files | 85.71 | 100 | 83.33 | 85.71 |
math.js | 100 | 100 | 100 | 100 |
userService.js | 71.43 | 100 | 50 | 71.43 | 7-9
--------------------|---------|----------|---------|---------|-------------------
# Configure coverage thresholds
// jest.config.js
module.exports = {
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
},
'./src/utils/': {
branches: 90,
functions: 90,
lines: 90,
statements: 90
}
}
};
Coverage Report Types
- Statements: Has each statement been executed?
- Branches: Has each branch (if/else) been executed?
- Functions: Has each function been called?
- Lines: Has each line been executed?
Watch Mode
Jest's watch mode is like having a personal assistant that runs tests automatically when files change:
# Start Jest in watch mode
npm test -- --watch
# Watch mode options
Watch Usage
› Press f to run only failed tests.
› Press o to only run tests related to changed files.
› Press p to filter by a filename regex pattern.
› Press t to filter by a test name regex pattern.
› Press q to quit watch mode.
› Press Enter to trigger a test run.
# Configure watch plugins
// jest.config.js
module.exports = {
watchPlugins: [
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname',
'jest-watch-select-projects'
]
};
Best Practices
Follow these best practices to write effective tests:
1. Write Descriptive Test Names
// Bad
test('test 1', () => { /* ... */ });
// Good
test('should return null when user is not found', () => { /* ... */ });
test('throws error when dividing by zero', () => { /* ... */ });
test('formats currency with two decimal places', () => { /* ... */ });
2. Keep Tests Independent
// Bad - Tests depend on shared state
let counter = 0;
test('increments counter', () => {
counter++;
expect(counter).toBe(1);
});
test('increments counter again', () => {
counter++;
expect(counter).toBe(2); // Fails if tests run in different order
});
// Good - Each test is independent
test('increments counter', () => {
let counter = 0;
counter++;
expect(counter).toBe(1);
});
test('increments counter from any value', () => {
let counter = 5;
counter++;
expect(counter).toBe(6);
});
3. Test One Thing at a Time
// Bad - Testing multiple things
test('user service works', () => {
const user = createUser({ name: 'John' });
expect(user.id).toBeDefined();
expect(user.name).toBe('John');
const updated = updateUser(user.id, { name: 'Jane' });
expect(updated.name).toBe('Jane');
deleteUser(user.id);
expect(getUser(user.id)).toBeNull();
});
// Good - Separate tests for each operation
describe('User Service', () => {
test('creates user with generated ID', () => {
const user = createUser({ name: 'John' });
expect(user.id).toBeDefined();
expect(user.name).toBe('John');
});
test('updates user name', () => {
const user = createUser({ name: 'John' });
const updated = updateUser(user.id, { name: 'Jane' });
expect(updated.name).toBe('Jane');
});
test('deletes user', () => {
const user = createUser({ name: 'John' });
deleteUser(user.id);
expect(getUser(user.id)).toBeNull();
});
});
4. Use Meaningful Assertions
// Bad - Generic assertions
expect(result).toBeTruthy();
expect(error).toBeFalsy();
// Good - Specific assertions
expect(result).toEqual({ id: 1, name: 'John' });
expect(error).toBeNull();
expect(users).toHaveLength(3);
expect(response.status).toBe(200);
Common Testing Patterns
1. Testing Error Conditions
function validateEmail(email) {
if (!email) {
throw new Error('Email is required');
}
if (!email.includes('@')) {
throw new Error('Invalid email format');
}
return true;
}
describe('validateEmail', () => {
test('throws error when email is empty', () => {
expect(() => validateEmail('')).toThrow('Email is required');
});
test('throws error for invalid format', () => {
expect(() => validateEmail('invalid')).toThrow('Invalid email format');
});
test('returns true for valid email', () => {
expect(validateEmail('test@example.com')).toBe(true);
});
});
2. Testing Async Operations
// API service
async function fetchUserProfile(userId) {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
}
// Test file
describe('fetchUserProfile', () => {
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
test('fetches user successfully', async () => {
const mockUser = { id: 1, name: 'John' };
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser)
});
const user = await fetchUserProfile(1);
expect(fetch).toHaveBeenCalledWith('/api/users/1');
expect(user).toEqual(mockUser);
});
test('handles API errors', async () => {
fetch.mockResolvedValueOnce({
ok: false,
status: 404
});
await expect(fetchUserProfile(999))
.rejects
.toThrow('Failed to fetch user');
});
});
3. Testing Event Handlers
// Component with event handler
class ClickCounter {
constructor() {
this.count = 0;
this.button = document.createElement('button');
this.button.textContent = 'Click me';
this.button.addEventListener('click', () => this.increment());
}
increment() {
this.count++;
this.button.textContent = `Clicked ${this.count} times`;
}
}
// Test
describe('ClickCounter', () => {
let counter;
beforeEach(() => {
counter = new ClickCounter();
});
test('increments count on click', () => {
// Simulate click
counter.button.click();
expect(counter.count).toBe(1);
expect(counter.button.textContent).toBe('Clicked 1 times');
});
test('handles multiple clicks', () => {
counter.button.click();
counter.button.click();
counter.button.click();
expect(counter.count).toBe(3);
expect(counter.button.textContent).toBe('Clicked 3 times');
});
});
Practical Exercise: Testing a Todo App
Let's practice by testing a simple Todo application:
// todo.js
export class TodoList {
constructor() {
this.todos = [];
this.nextId = 1;
}
addTodo(text) {
if (!text || text.trim() === '') {
throw new Error('Todo text cannot be empty');
}
const todo = {
id: this.nextId++,
text: text.trim(),
completed: false,
createdAt: new Date()
};
this.todos.push(todo);
return todo;
}
toggleTodo(id) {
const todo = this.todos.find(t => t.id === id);
if (!todo) {
throw new Error('Todo not found');
}
todo.completed = !todo.completed;
return todo;
}
deleteTodo(id) {
const index = this.todos.findIndex(t => t.id === id);
if (index === -1) {
throw new Error('Todo not found');
}
return this.todos.splice(index, 1)[0];
}
getActiveTodos() {
return this.todos.filter(todo => !todo.completed);
}
getCompletedTodos() {
return this.todos.filter(todo => todo.completed);
}
}
// todo.test.js
import { TodoList } from './todo';
describe('TodoList', () => {
let todoList;
beforeEach(() => {
todoList = new TodoList();
});
describe('addTodo', () => {
test('adds a new todo', () => {
const todo = todoList.addTodo('Buy groceries');
expect(todo).toEqual({
id: 1,
text: 'Buy groceries',
completed: false,
createdAt: expect.any(Date)
});
expect(todoList.todos).toHaveLength(1);
});
test('trims whitespace from todo text', () => {
const todo = todoList.addTodo(' Clean house ');
expect(todo.text).toBe('Clean house');
});
test('throws error for empty todo', () => {
expect(() => todoList.addTodo('')).toThrow('Todo text cannot be empty');
expect(() => todoList.addTodo(' ')).toThrow('Todo text cannot be empty');
});
test('increments ID for each todo', () => {
const todo1 = todoList.addTodo('First todo');
const todo2 = todoList.addTodo('Second todo');
expect(todo1.id).toBe(1);
expect(todo2.id).toBe(2);
});
});
describe('toggleTodo', () => {
test('toggles todo completion status', () => {
const todo = todoList.addTodo('Test todo');
expect(todo.completed).toBe(false);
const toggledTodo = todoList.toggleTodo(todo.id);
expect(toggledTodo.completed).toBe(true);
const toggledAgain = todoList.toggleTodo(todo.id);
expect(toggledAgain.completed).toBe(false);
});
test('throws error for non-existent todo', () => {
expect(() => todoList.toggleTodo(999)).toThrow('Todo not found');
});
});
describe('deleteTodo', () => {
test('deletes todo by ID', () => {
const todo1 = todoList.addTodo('Todo 1');
const todo2 = todoList.addTodo('Todo 2');
const deleted = todoList.deleteTodo(todo1.id);
expect(deleted).toEqual(todo1);
expect(todoList.todos).toHaveLength(1);
expect(todoList.todos[0]).toEqual(todo2);
});
test('throws error when todo not found', () => {
expect(() => todoList.deleteTodo(999)).toThrow('Todo not found');
});
});
describe('filtering todos', () => {
beforeEach(() => {
todoList.addTodo('Active todo 1');
todoList.addTodo('Active todo 2');
todoList.addTodo('Completed todo');
todoList.toggleTodo(3); // Complete the third todo
});
test('getActiveTodos returns only active todos', () => {
const activeTodos = todoList.getActiveTodos();
expect(activeTodos).toHaveLength(2);
expect(activeTodos.every(todo => !todo.completed)).toBe(true);
});
test('getCompletedTodos returns only completed todos', () => {
const completedTodos = todoList.getCompletedTodos();
expect(completedTodos).toHaveLength(1);
expect(completedTodos[0].text).toBe('Completed todo');
expect(completedTodos[0].completed).toBe(true);
});
});
});
Summary and Next Steps
Today we've learned the fundamentals of testing with Jest:
- Setting up Jest in your project
- Writing basic tests with describe, test, and expect
- Using various matchers for different assertions
- Testing asynchronous code with promises and async/await
- Using setup and teardown hooks
- Mocking functions and modules
- Testing with fake timers
- Using snapshot testing
- Understanding code coverage
- Following testing best practices
Practice Exercises
- Write tests for a shopping cart class that handles adding items, removing items, and calculating totals
- Create tests for an API client that handles different HTTP methods
- Test a form validation function with various input scenarios
- Write snapshot tests for a React component
- Practice mocking external dependencies like localStorage or fetch
Remember: Good tests are an investment in your code's future. They give you confidence to refactor, add features, and fix bugs without fear of breaking existing functionality!