Unit Testing Deep Dive
Master unit testing with Jest: mocking, spies, test organization, and best practices
What Makes a Good Unit Test?
A unit test focuses on testing a single "unit" of code in isolation. This could be a function, a method, or a component. The key is isolating the code under test from its dependencies.
✅ Good Unit Test Characteristics
- • Fast (milliseconds)
- • Isolated (no external dependencies)
- • Repeatable (same result every time)
- • Self-validating (clear pass/fail)
- • Timely (written with or before code)
🎯 FIRST Principles
- Fast - Run quickly
- Isolated - Independent of others
- Repeatable - Consistent results
- Self-validating - No manual checking
- Timely - Written at the right time
Jest Basics
// math.js - Simple functions to test
export function add(a, b) {
return a + b;
}
export function divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
export function isEven(num) {
return num % 2 === 0;
}
export function factorial(n) {
if (n < 0) throw new Error('Negative number');
if (n === 0 || n === 1) return 1;
return n * factorial(n - 1);
}
// math.test.js - Basic tests
import { add, divide, isEven, factorial } from './math';
describe('Math utilities', () => {
describe('add', () => {
test('adds positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
test('adds negative numbers', () => {
expect(add(-2, -3)).toBe(-5);
});
test('adds mixed numbers', () => {
expect(add(-2, 3)).toBe(1);
});
});
describe('divide', () => {
test('divides numbers correctly', () => {
expect(divide(10, 2)).toBe(5);
});
test('throws error on division by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
});
describe('isEven', () => {
test.each([
[2, true],
[3, false],
[0, true],
[-2, true],
[-3, false],
])('isEven(%i) returns %s', (num, expected) => {
expect(isEven(num)).toBe(expected);
});
});
describe('factorial', () => {
test('calculates factorial correctly', () => {
expect(factorial(0)).toBe(1);
expect(factorial(1)).toBe(1);
expect(factorial(5)).toBe(120);
});
test('throws error for negative numbers', () => {
expect(() => factorial(-1)).toThrow('Negative number');
});
});
});
Jest Matchers Reference
Common Matchers
// Equality
expect(value).toBe(5); // Strict equality (===)
expect(value).toEqual({ a: 1 }); // Deep equality
expect(value).not.toBe(5); // Negation
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3);
expect(value).toBeLessThan(5);
expect(value).toBeCloseTo(0.3); // Floating point
// Strings
expect(str).toMatch(/pattern/);
expect(str).toContain('substring');
// Arrays
expect(arr).toContain(item);
expect(arr).toHaveLength(3);
// Objects
expect(obj).toHaveProperty('key');
expect(obj).toMatchObject({ a: 1 });
// Functions
expect(fn).toThrow();
expect(fn).toThrow('Error message');
expect(fn).toHaveBeenCalled();
expect(fn).toHaveBeenCalledWith(arg1, arg2);
Async Matchers
// Promises
test('async test', async () => {
await expect(promise).resolves.toBe(value);
await expect(promise).rejects.toThrow();
});
// Alternative syntax
test('async test', () => {
return expect(promise).resolves.toBe(value);
});
// Using async/await
test('async test', async () => {
const result = await asyncFunction();
expect(result).toBe(expected);
});
// Testing callbacks
test('callback test', (done) => {
function callback(data) {
expect(data).toBe('result');
done();
}
asyncFunction(callback);
});
Mocking with Jest
Mocks replace dependencies with controlled implementations for isolated testing.
Mock Functions
// Creating mock functions
const mockFn = jest.fn();
// Mock with return value
const mockFn = jest.fn(() => 'return value');
const mockFn = jest.fn().mockReturnValue('value');
// Mock with different return values
const mockFn = jest.fn()
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
.mockReturnValue('default');
console.log(mockFn()); // 'first'
console.log(mockFn()); // 'second'
console.log(mockFn()); // 'default'
console.log(mockFn()); // 'default'
// Mock async functions
const mockAsync = jest.fn().mockResolvedValue('success');
const mockAsyncError = jest.fn().mockRejectedValue(new Error('failed'));
// Checking mock calls
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(4);
expect(mockFn).toHaveBeenCalledWith(arg1, arg2);
expect(mockFn).toHaveBeenLastCalledWith(arg);
// Accessing call data
console.log(mockFn.mock.calls); // All calls: [[args1], [args2]]
console.log(mockFn.mock.results); // All results
console.log(mockFn.mock.instances); // All instances (for constructors)
Mocking Modules
// api.js - Module to mock
export async function fetchUser(id) {
const response = await fetch('/api/users/' + id);
return response.json();
}
export async function createUser(data) {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data),
});
return response.json();
}
// userService.js - Code that uses the API
import { fetchUser, createUser } from './api';
export async function getUserName(id) {
const user = await fetchUser(id);
return user.name;
}
export async function registerUser(name, email) {
const user = await createUser({ name, email });
return user.id;
}
// userService.test.js - Test with mocked API
import { getUserName, registerUser } from './userService';
import * as api from './api';
// Mock the entire module
jest.mock('./api');
describe('User Service', () => {
beforeEach(() => {
// Clear all mocks before each test
jest.clearAllMocks();
});
test('getUserName returns user name', async () => {
// Setup mock return value
api.fetchUser.mockResolvedValue({
id: 1,
name: 'Alice',
email: 'alice@example.com',
});
const name = await getUserName(1);
expect(name).toBe('Alice');
expect(api.fetchUser).toHaveBeenCalledWith(1);
expect(api.fetchUser).toHaveBeenCalledTimes(1);
});
test('registerUser returns new user id', async () => {
api.createUser.mockResolvedValue({
id: 123,
name: 'Bob',
email: 'bob@example.com',
});
const id = await registerUser('Bob', 'bob@example.com');
expect(id).toBe(123);
expect(api.createUser).toHaveBeenCalledWith({
name: 'Bob',
email: 'bob@example.com',
});
});
test('handles API errors', async () => {
api.fetchUser.mockRejectedValue(new Error('Network error'));
await expect(getUserName(1)).rejects.toThrow('Network error');
});
});
Spies and Partial Mocks
// Spy on a method without changing implementation
const obj = {
method: () => 'original',
};
const spy = jest.spyOn(obj, 'method');
console.log(obj.method()); // 'original' - still works
expect(spy).toHaveBeenCalled();
// Spy and change implementation
const spy = jest.spyOn(obj, 'method').mockReturnValue('mocked');
console.log(obj.method()); // 'mocked'
// Restore original
spy.mockRestore();
console.log(obj.method()); // 'original'
// Partial module mock
jest.mock('./utils', () => ({
...jest.requireActual('./utils'), // Keep other exports
fetchData: jest.fn(), // Mock only this one
}));
Testing Async Code
// Example async functions
async function fetchData() {
const response = await fetch('/api/data');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
}
function fetchDataCallback(callback) {
setTimeout(() => {
callback({ data: 'result' });
}, 100);
}
// Testing with async/await
test('fetches data successfully', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: 'test' }),
});
const data = await fetchData();
expect(data).toEqual({ data: 'test' });
});
// Testing error cases
test('throws error on failed fetch', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
});
await expect(fetchData()).rejects.toThrow('Failed to fetch');
});
// Testing with callbacks
test('callback receives data', (done) => {
function callback(data) {
try {
expect(data).toEqual({ data: 'result' });
done();
} catch (error) {
done(error);
}
}
fetchDataCallback(callback);
});
// Testing with promises
test('returns a promise', () => {
return expect(fetchData()).resolves.toEqual({ data: 'test' });
});
Setup and Teardown
describe('Database tests', () => {
let db;
// Run once before all tests in this describe block
beforeAll(async () => {
db = await connectDatabase();
});
// Run once after all tests in this describe block
afterAll(async () => {
await db.disconnect();
});
// Run before each test
beforeEach(async () => {
await db.clear();
await db.seed();
});
// Run after each test
afterEach(async () => {
await db.clearLogs();
});
test('can insert user', async () => {
const user = { name: 'Alice' };
await db.users.insert(user);
const found = await db.users.findOne({ name: 'Alice' });
expect(found.name).toBe('Alice');
});
test('can delete user', async () => {
await db.users.insert({ name: 'Bob' });
await db.users.delete({ name: 'Bob' });
const found = await db.users.findOne({ name: 'Bob' });
expect(found).toBeNull();
});
});
Test Organization Best Practices
📁 File Structure
src/
components/
Button/
Button.jsx
Button.test.jsx
Button.styles.css
utils/
math.js
math.test.js
__tests__/
integration/
userFlow.test.js
Keep tests close to the code they test, or in a dedicated __tests__ folder.
✍️ Test Naming
// ❌ Bad: Unclear what's being tested
test('it works', () => { ... });
test('test1', () => { ... });
// ✅ Good: Describes behavior clearly
test('returns user name when user exists', () => { ... });
test('throws error when user not found', () => { ... });
test('calculates total with discount applied', () => { ... });
// ✅ Good: Using describe blocks for organization
describe('ShoppingCart', () => {
describe('addItem', () => {
test('adds item to empty cart', () => { ... });
test('increments quantity when item already exists', () => { ... });
test('throws error when item is invalid', () => { ... });
});
describe('removeItem', () => {
test('removes item completely', () => { ... });
test('decrements quantity when count > 1', () => { ... });
});
});
Code Coverage
// Run tests with coverage
npm test -- --coverage
// Output example:
// -------------------|---------|----------|---------|---------|
// File | % Stmts | % Branch | % Funcs | % Lines |
// -------------------|---------|----------|---------|---------|
// All files | 85.5 | 78.2 | 90.1 | 84.8 |
// math.js | 100 | 100 | 100 | 100 |
// userService.js | 80.5 | 66.7 | 85.0 | 79.2 |
// -------------------|---------|----------|---------|---------|
⚠️ Coverage Isn't Everything
- • 100% coverage doesn't mean bug-free code
- • Focus on testing important behavior, not hitting a number
- • Aim for 70-80% coverage as a reasonable target
- • Critical paths should have high coverage
💡 Unit Testing Best Practices
- ✓ Test one thing per test
- ✓ Use descriptive test names that explain the behavior
- ✓ Arrange-Act-Assert pattern for clarity
- ✓ Mock external dependencies to keep tests fast and isolated
- ✓ Test edge cases and error conditions
- ✓ Keep tests simple and readable
- ✓ Don't test implementation details, test behavior
- ✓ Run tests frequently during development
📚 More Testing Topics
Explore all 6 testing topics to build a comprehensive understanding of software testing.
View All Topics