Why Test?
Testing ensures code works correctly and prevents regressions when making changes.
// Simple test without a framework
function add(a, b) {
return a + b;
}
// Manual testing
function testAdd() {
const result = add(2, 3);
const expected = 5;
if (result === expected) {
console.log("✓ add(2, 3) = 5");
} else {
console.error(`✗ Expected ${expected}, got ${result}`);
}
}
testAdd();
// Simple assertion function
function assert(condition, message) {
if (!condition) {
throw new Error(message || "Assertion failed");
}
}
assert(add(2, 3) === 5, "2 + 3 should equal 5");
assert(add(-1, 1) === 0, "-1 + 1 should equal 0");
assert(add(0, 0) === 0, "0 + 0 should equal 0");
- Unit tests - Test individual functions/modules
- Integration tests - Test components working together
- End-to-end tests - Test complete user workflows
Jest Testing Framework
Jest is a popular testing framework with built-in assertions and mocking.
// math.js
export function add(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, divide } from "./math";
// Test suite
describe("Math functions", () => {
// Individual test
test("adds two numbers", () => {
expect(add(2, 3)).toBe(5);
expect(add(-1, 1)).toBe(0);
expect(add(0, 0)).toBe(0);
});
// Alternative syntax: it() instead of test()
it("divides two numbers", () => {
expect(divide(10, 2)).toBe(5);
expect(divide(9, 3)).toBe(3);
});
// Testing errors
it("throws on division by zero", () => {
expect(() => divide(10, 0)).toThrow("Cannot divide by zero");
});
});
// ===== Common Matchers =====
test("matchers examples", () => {
// Equality
expect(2 + 2).toBe(4); // Exact equality
expect({ a: 1 }).toEqual({ a: 1 }); // Deep equality
// Truthiness
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect("value").toBeDefined();
expect(true).toBeTruthy();
expect(false).toBeFalsy();
// Numbers
expect(2 + 2).toBeGreaterThan(3);
expect(2 + 2).toBeGreaterThanOrEqual(4);
expect(2 + 2).toBeLessThan(5);
expect(0.1 + 0.2).toBeCloseTo(0.3); // Floating point
// Strings
expect("hello world").toMatch(/world/);
expect("hello world").toContain("world");
// Arrays
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);
// Objects
expect({ a: 1, b: 2 }).toHaveProperty("a");
expect({ a: 1, b: 2 }).toHaveProperty("a", 1);
// Negation
expect(1).not.toBe(2);
});
Testing Async Code
Test Promises, async/await, and callback functions.
// api.js
export async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error("User not found");
return response.json();
}
export function fetchUserCallback(id, callback) {
fetch(`/api/users/${id}`)
.then(res => res.json())
.then(data => callback(null, data))
.catch(err => callback(err, null));
}
// api.test.js
import { fetchUser, fetchUserCallback } from "./api";
// Testing with async/await
test("fetches user data", async () => {
const user = await fetchUser(1);
expect(user).toHaveProperty("name");
expect(user.id).toBe(1);
});
// Testing with Promises
test("fetches user with promises", () => {
return fetchUser(1).then(user => {
expect(user.id).toBe(1);
});
});
// Testing rejections
test("throws for invalid user", async () => {
await expect(fetchUser(999)).rejects.toThrow("User not found");
});
// Testing callbacks
test("fetches with callback", (done) => {
fetchUserCallback(1, (error, user) => {
expect(error).toBeNull();
expect(user.id).toBe(1);
done(); // Signal test completion
});
});
// Testing with resolves/rejects matchers
test("resolves to user object", () => {
return expect(fetchUser(1)).resolves.toHaveProperty("name");
});
test("rejects for missing user", () => {
return expect(fetchUser(999)).rejects.toThrow();
});
Mocking
Replace dependencies with mock functions for isolated testing.
// ===== Mock Functions =====
test("mock function basics", () => {
const mockFn = jest.fn();
mockFn("hello");
mockFn("world");
// Check calls
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith("hello");
expect(mockFn).toHaveBeenLastCalledWith("world");
// Get call arguments
expect(mockFn.mock.calls[0][0]).toBe("hello");
expect(mockFn.mock.calls[1][0]).toBe("world");
});
// Mock with return values
test("mock return values", () => {
const mockFn = jest.fn()
.mockReturnValueOnce(10)
.mockReturnValueOnce(20)
.mockReturnValue(0);
expect(mockFn()).toBe(10);
expect(mockFn()).toBe(20);
expect(mockFn()).toBe(0);
expect(mockFn()).toBe(0);
});
// Mock implementation
test("mock implementation", () => {
const mockFn = jest.fn((x) => x * 2);
expect(mockFn(5)).toBe(10);
});
// ===== Mocking Modules =====
// Mock fetch globally
global.fetch = jest.fn();
beforeEach(() => {
fetch.mockClear();
});
test("mocking fetch", async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ id: 1, name: "John" })
});
const user = await fetchUser(1);
expect(fetch).toHaveBeenCalledWith("/api/users/1");
expect(user.name).toBe("John");
});
test("mocking fetch error", async () => {
fetch.mockResolvedValueOnce({
ok: false
});
await expect(fetchUser(999)).rejects.toThrow("User not found");
});
// ===== Spying =====
const calculator = {
add: (a, b) => a + b,
multiply: (a, b) => a * b
};
test("spying on methods", () => {
const spy = jest.spyOn(calculator, "add");
calculator.add(2, 3);
expect(spy).toHaveBeenCalledWith(2, 3);
expect(spy).toHaveReturnedWith(5);
spy.mockRestore(); // Restore original
});
Setup & Teardown
Run code before and after tests for proper setup and cleanup.
describe("Database tests", () => {
let db;
// Run once before all tests in this describe
beforeAll(async () => {
db = await connectToDatabase();
});
// Run once after all tests
afterAll(async () => {
await db.disconnect();
});
// Run before each test
beforeEach(async () => {
await db.clear();
await db.seed();
});
// Run after each test
afterEach(() => {
jest.clearAllMocks();
});
test("finds users", async () => {
const users = await db.find("users");
expect(users.length).toBeGreaterThan(0);
});
test("creates user", async () => {
await db.insert("users", { name: "Test" });
const users = await db.find("users");
expect(users).toContainEqual(
expect.objectContaining({ name: "Test" })
);
});
});
// Scoping - setup/teardown only applies to describe block
describe("outer", () => {
beforeEach(() => console.log("outer beforeEach"));
test("outer test", () => {});
describe("inner", () => {
beforeEach(() => console.log("inner beforeEach"));
test("inner test", () => {});
// Logs: "outer beforeEach" then "inner beforeEach"
});
});
Test-Driven Development
Write tests before implementation to drive design.
// TDD Cycle: Red -> Green -> Refactor
// 1. RED: Write a failing test first
describe("ShoppingCart", () => {
test("starts empty", () => {
const cart = new ShoppingCart();
expect(cart.items).toEqual([]);
expect(cart.total).toBe(0);
});
test("adds items", () => {
const cart = new ShoppingCart();
cart.addItem({ name: "Apple", price: 1.50 });
expect(cart.items).toHaveLength(1);
expect(cart.items[0].name).toBe("Apple");
});
test("calculates total", () => {
const cart = new ShoppingCart();
cart.addItem({ name: "Apple", price: 1.50 });
cart.addItem({ name: "Banana", price: 0.75 });
expect(cart.total).toBe(2.25);
});
test("removes items", () => {
const cart = new ShoppingCart();
cart.addItem({ name: "Apple", price: 1.50, id: 1 });
cart.removeItem(1);
expect(cart.items).toHaveLength(0);
});
});
// 2. GREEN: Write minimal code to pass
class ShoppingCart {
constructor() {
this.items = [];
}
get total() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
addItem(item) {
this.items.push(item);
}
removeItem(id) {
this.items = this.items.filter(item => item.id !== id);
}
}
// 3. REFACTOR: Improve code while keeping tests green
class ShoppingCart {
#items = [];
get items() {
return [...this.#items]; // Return copy
}
get total() {
return this.#items.reduce((sum, item) => sum + item.price, 0);
}
get itemCount() {
return this.#items.length;
}
addItem(item) {
if (!item.price || item.price < 0) {
throw new Error("Invalid item price");
}
this.#items.push({ ...item, id: item.id || Date.now() });
}
removeItem(id) {
const index = this.#items.findIndex(item => item.id === id);
if (index > -1) {
this.#items.splice(index, 1);
}
}
clear() {
this.#items = [];
}
}
Advanced Mocking Strategies
Effective mocking isolates your code from external dependencies, making tests faster, more reliable, and focused on the unit under test.
Module Mocking
// Mock entire module
jest.mock('./api', () => ({
fetchUsers: jest.fn(() => Promise.resolve([{ id: 1, name: 'Test' }])),
createUser: jest.fn(() => Promise.resolve({ id: 2, name: 'New' }))
}));
// Mock with factory (hoisted to top)
jest.mock('./database', () => {
return {
query: jest.fn(),
connect: jest.fn(() => Promise.resolve()),
disconnect: jest.fn()
};
});
// Partial mock (keep some real implementations)
jest.mock('./utils', () => {
const actual = jest.requireActual('./utils');
return {
...actual,
sendEmail: jest.fn() // Mock only sendEmail
};
});
// Mock third-party modules
jest.mock('axios');
import axios from 'axios';
axios.get.mockResolvedValue({ data: { users: [] } });
axios.post.mockRejectedValue(new Error('Network error'));
// Mock per-test with different values
beforeEach(() => {
axios.get.mockClear(); // Clear mock call history
});
test('handles success', async () => {
axios.get.mockResolvedValueOnce({ data: { id: 1 } });
const result = await fetchUser(1);
expect(result.id).toBe(1);
});
test('handles error', async () => {
axios.get.mockRejectedValueOnce(new Error('Not found'));
await expect(fetchUser(999)).rejects.toThrow('Not found');
});
Spy and Mock Functions
// Spy on existing method
const user = {
getName: () => 'John',
getAge: () => 30
};
const spy = jest.spyOn(user, 'getName');
user.getName();
expect(spy).toHaveBeenCalled();
expect(spy).toHaveReturnedWith('John');
// Replace implementation temporarily
spy.mockReturnValue('Jane');
expect(user.getName()).toBe('Jane');
// Restore original
spy.mockRestore();
expect(user.getName()).toBe('John');
// Mock implementation with custom logic
const mockFn = jest.fn()
.mockImplementationOnce(() => 'first call')
.mockImplementationOnce(() => 'second call')
.mockImplementation(() => 'default');
console.log(mockFn()); // 'first call'
console.log(mockFn()); // 'second call'
console.log(mockFn()); // 'default'
// Capture arguments
const callback = jest.fn();
[1, 2, 3].forEach(callback);
expect(callback.mock.calls).toEqual([[1, 0, [1,2,3]], [2, 1, [1,2,3]], [3, 2, [1,2,3]]]);
expect(callback.mock.calls[0][0]).toBe(1);
// Mock timers
jest.useFakeTimers();
const callback = jest.fn();
setTimeout(callback, 1000);
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
jest.useRealTimers();
Integration Testing
Integration tests verify that multiple units work together correctly. They test the interactions between components, services, and external systems.
// Test API endpoint with supertest
import request from 'supertest';
import app from '../src/app';
import { db } from '../src/database';
describe('Users API', () => {
beforeAll(async () => {
await db.connect();
});
afterAll(async () => {
await db.disconnect();
});
beforeEach(async () => {
await db.collection('users').deleteMany({});
});
test('POST /users creates a new user', async () => {
const response = await request(app)
.post('/users')
.send({ name: 'John', email: 'john@example.com' })
.expect('Content-Type', /json/)
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(String),
name: 'John',
email: 'john@example.com'
});
// Verify database
const user = await db.collection('users').findOne({ email: 'john@example.com' });
expect(user).toBeTruthy();
expect(user.name).toBe('John');
});
test('GET /users returns all users', async () => {
// Setup test data
await db.collection('users').insertMany([
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' }
]);
const response = await request(app)
.get('/users')
.expect(200);
expect(response.body).toHaveLength(2);
expect(response.body[0]).toHaveProperty('name');
});
test('handles validation errors', async () => {
const response = await request(app)
.post('/users')
.send({ name: '' }) // Missing email
.expect(400);
expect(response.body.errors).toContain('Email is required');
});
});
// Test component interactions
describe('UserDashboard Integration', () => {
test('loads and displays user data', async () => {
const { getByText, findByText } = render( );
// Loading state
expect(getByText('Loading...')).toBeInTheDocument();
// Wait for data
await findByText('John Doe');
// Verify interactions work
fireEvent.click(getByText('Edit Profile'));
await findByText('Edit Profile');
});
});
- Test behavior, not implementation: Focus on what code does, not how
- Keep tests independent: Each test should run in isolation
- Use descriptive names: Test names should explain expected behavior
- Follow AAA pattern: Arrange, Act, Assert for clear structure
- Test edge cases: Empty arrays, null values, boundaries
Summary
Test Structure
describe, test/it, expect
Matchers
toBe, toEqual, toThrow, etc.
Async Testing
async/await, resolves/rejects
Mocking
jest.fn(), jest.spyOn()
Setup/Teardown
beforeEach, afterEach, beforeAll
TDD
Red → Green → Refactor