Testing with Jest
Introduction
Testing is essential for building reliable software. Jest is a popular JavaScript testing framework that works excellently with TypeScript, providing a delightful testing experience with built-in assertions, mocking, and coverage reporting.
Why test your code?
- Catch bugs before they reach production
- Document how your code should work
- Enable safe refactoring
- Increase confidence in your code
- Save time debugging
Testing Philosophy
The Testing Pyramid:
/\
/ \ E2E Tests (Few)
/____\
/ \ Integration Tests (Some)
/________\
/ \ Unit Tests (Many)
/____________\
Concepts
Setting Up Jest with TypeScript
Installation:
npm install --save-dev jest ts-jest @types/jest
npx ts-jest config:init
jest.config.js:
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts'
]
};
Basic Test Structure
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function multiply(a: number, b: number): number {
return a * b;
}
// math.test.ts
import { add, multiply } from './math';
describe('Math functions', () => {
test('adds two numbers', () => {
expect(add(2, 3)).toBe(5);
expect(add(-1, 1)).toBe(0);
expect(add(0, 0)).toBe(0);
});
test('multiplies two numbers', () => {
expect(multiply(3, 4)).toBe(12);
expect(multiply(0, 5)).toBe(0);
expect(multiply(-2, 3)).toBe(-6);
});
});
Common Matchers
describe('Jest matchers', () => {
test('equality matchers', () => {
expect(2 + 2).toBe(4); // Strict equality
expect({ name: 'Alice' }).toEqual({ name: 'Alice' }); // Deep equality
});
test('truthiness matchers', () => {
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect('hello').toBeDefined();
});
test('number matchers', () => {
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(5).toBeLessThan(10);
expect(5).toBeLessThanOrEqual(5);
expect(0.1 + 0.2).toBeCloseTo(0.3); // Floating point
});
test('string matchers', () => {
expect('hello world').toMatch(/world/);
expect('hello').toContain('ell');
});
test('array matchers', () => {
const arr = [1, 2, 3];
expect(arr).toContain(2);
expect(arr).toHaveLength(3);
expect(arr).toEqual(expect.arrayContaining([1, 2]));
});
test('object matchers', () => {
const obj = { name: 'Alice', age: 30 };
expect(obj).toHaveProperty('name');
expect(obj).toHaveProperty('name', 'Alice');
expect(obj).toMatchObject({ name: 'Alice' });
});
});
Testing Async Code
// api.ts
export async function fetchUser(id: number): Promise<{ name: string }> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ name: 'Alice' });
}, 100);
});
}
// api.test.ts
import { fetchUser } from './api';
describe('Async tests', () => {
test('fetches user with async/await', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});
test('fetches user with promises', () => {
return fetchUser(1).then(user => {
expect(user.name).toBe('Alice');
});
});
test('resolves promise', async () => {
await expect(fetchUser(1)).resolves.toEqual({ name: 'Alice' });
});
test('rejects promise', async () => {
const errorFn = async () => {
throw new Error('Failed');
};
await expect(errorFn()).rejects.toThrow('Failed');
});
});
Setup and Teardown
describe('Setup and teardown', () => {
let testData: number[];
// Runs before all tests in this describe block
beforeAll(() => {
console.log('Setting up test suite');
});
// Runs after all tests in this describe block
afterAll(() => {
console.log('Cleaning up test suite');
});
// Runs before each test
beforeEach(() => {
testData = [1, 2, 3];
});
// Runs after each test
afterEach(() => {
testData = [];
});
test('test 1', () => {
expect(testData).toHaveLength(3);
});
test('test 2', () => {
expect(testData[0]).toBe(1);
});
});
Mocking
// Mock functions
test('mock function', () => {
const mockFn = jest.fn();
mockFn('hello');
mockFn('world');
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenLastCalledWith('world');
});
// Mock return values
test('mock return value', () => {
const mockFn = jest.fn();
mockFn.mockReturnValue(42);
expect(mockFn()).toBe(42);
});
// Mock implementations
test('mock implementation', () => {
const mockFn = jest.fn((x: number) => x * 2);
expect(mockFn(5)).toBe(10);
expect(mockFn).toHaveBeenCalledWith(5);
});
Examples
Testing a Calculator Class
// calculator.ts
export class Calculator {
add(a: number, b: number): number {
return a + b;
}
subtract(a: number, b: number): number {
return a - b;
}
multiply(a: number, b: number): number {
return a * b;
}
divide(a: number, b: number): number {
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
}
power(base: number, exponent: number): number {
return Math.pow(base, exponent);
}
}
// calculator.test.ts
import { Calculator } from './calculator';
describe('Calculator', () => {
let calc: Calculator;
beforeEach(() => {
calc = new Calculator();
});
describe('add', () => {
test('adds positive numbers', () => {
expect(calc.add(2, 3)).toBe(5);
});
test('adds negative numbers', () => {
expect(calc.add(-2, -3)).toBe(-5);
});
test('adds mixed numbers', () => {
expect(calc.add(-5, 10)).toBe(5);
});
});
describe('subtract', () => {
test('subtracts numbers', () => {
expect(calc.subtract(10, 3)).toBe(7);
expect(calc.subtract(3, 10)).toBe(-7);
});
});
describe('multiply', () => {
test('multiplies numbers', () => {
expect(calc.multiply(3, 4)).toBe(12);
expect(calc.multiply(-2, 5)).toBe(-10);
});
});
describe('divide', () => {
test('divides numbers', () => {
expect(calc.divide(10, 2)).toBe(5);
expect(calc.divide(7, 2)).toBe(3.5);
});
test('throws error on division by zero', () => {
expect(() => calc.divide(10, 0)).toThrow('Division by zero');
});
});
describe('power', () => {
test('calculates power', () => {
expect(calc.power(2, 3)).toBe(8);
expect(calc.power(5, 2)).toBe(25);
expect(calc.power(10, 0)).toBe(1);
});
});
});
Testing User Service
// user.service.ts
interface User {
id: number;
name: string;
email: string;
}
export class UserService {
private users: Map<number, User> = new Map();
private nextId = 1;
create(name: string, email: string): User {
const user: User = {
id: this.nextId++,
name,
email
};
this.users.set(user.id, user);
return user;
}
findById(id: number): User | undefined {
return this.users.get(id);
}
findAll(): User[] {
return Array.from(this.users.values());
}
update(id: number, updates: Partial<User>): User | undefined {
const user = this.users.get(id);
if (!user) return undefined;
const updated = { ...user, ...updates, id };
this.users.set(id, updated);
return updated;
}
delete(id: number): boolean {
return this.users.delete(id);
}
}
// user.service.test.ts
import { UserService } from './user.service';
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
service = new UserService();
});
describe('create', () => {
test('creates a new user', () => {
const user = service.create('Alice', 'alice@example.com');
expect(user).toMatchObject({
id: expect.any(Number),
name: 'Alice',
email: 'alice@example.com'
});
});
test('assigns incremental IDs', () => {
const user1 = service.create('Alice', 'alice@example.com');
const user2 = service.create('Bob', 'bob@example.com');
expect(user2.id).toBe(user1.id + 1);
});
});
describe('findById', () => {
test('finds existing user', () => {
const created = service.create('Alice', 'alice@example.com');
const found = service.findById(created.id);
expect(found).toEqual(created);
});
test('returns undefined for non-existent user', () => {
expect(service.findById(999)).toBeUndefined();
});
});
describe('findAll', () => {
test('returns empty array initially', () => {
expect(service.findAll()).toEqual([]);
});
test('returns all users', () => {
service.create('Alice', 'alice@example.com');
service.create('Bob', 'bob@example.com');
const users = service.findAll();
expect(users).toHaveLength(2);
});
});
describe('update', () => {
test('updates existing user', () => {
const user = service.create('Alice', 'alice@example.com');
const updated = service.update(user.id, { name: 'Alice Smith' });
expect(updated).toMatchObject({
id: user.id,
name: 'Alice Smith',
email: 'alice@example.com'
});
});
test('returns undefined for non-existent user', () => {
expect(service.update(999, { name: 'Test' })).toBeUndefined();
});
});
describe('delete', () => {
test('deletes existing user', () => {
const user = service.create('Alice', 'alice@example.com');
expect(service.delete(user.id)).toBe(true);
expect(service.findById(user.id)).toBeUndefined();
});
test('returns false for non-existent user', () => {
expect(service.delete(999)).toBe(false);
});
});
});
Testing with Mocks
// email.service.ts
export interface EmailProvider {
send(to: string, subject: string, body: string): Promise<boolean>;
}
export class EmailService {
constructor(private provider: EmailProvider) {}
async sendWelcomeEmail(email: string, name: string): Promise<boolean> {
const subject = 'Welcome!';
const body = `Hello ${name}, welcome to our service!`;
return this.provider.send(email, subject, body);
}
async sendPasswordReset(email: string, token: string): Promise<boolean> {
const subject = 'Password Reset';
const body = `Your reset token is: ${token}`;
return this.provider.send(email, subject, body);
}
}
// email.service.test.ts
import { EmailService, EmailProvider } from './email.service';
describe('EmailService', () => {
let mockProvider: jest.Mocked<EmailProvider>;
let service: EmailService;
beforeEach(() => {
mockProvider = {
send: jest.fn()
};
service = new EmailService(mockProvider);
});
describe('sendWelcomeEmail', () => {
test('sends welcome email with correct parameters', async () => {
mockProvider.send.mockResolvedValue(true);
await service.sendWelcomeEmail('alice@example.com', 'Alice');
expect(mockProvider.send).toHaveBeenCalledWith(
'alice@example.com',
'Welcome!',
'Hello Alice, welcome to our service!'
);
});
test('returns true on success', async () => {
mockProvider.send.mockResolvedValue(true);
const result = await service.sendWelcomeEmail('alice@example.com', 'Alice');
expect(result).toBe(true);
});
test('returns false on failure', async () => {
mockProvider.send.mockResolvedValue(false);
const result = await service.sendWelcomeEmail('alice@example.com', 'Alice');
expect(result).toBe(false);
});
});
describe('sendPasswordReset', () => {
test('sends password reset email', async () => {
mockProvider.send.mockResolvedValue(true);
await service.sendPasswordReset('alice@example.com', 'abc123');
expect(mockProvider.send).toHaveBeenCalledWith(
'alice@example.com',
'Password Reset',
'Your reset token is: abc123'
);
});
});
});
Challenges
1. Test String Utils
- Create reverseString function
- Write comprehensive tests
- Test edge cases
💡 Show Answer
// stringUtils.ts
export function reverseString(str: string): string {
return str.split('').reverse().join('');
}
// stringUtils.test.ts
import { reverseString } from './stringUtils';
describe('reverseString', () => {
test('reverses a simple string', () => {
expect(reverseString('hello')).toBe('olleh');
});
test('reverses a single character', () => {
expect(reverseString('a')).toBe('a');
});
test('handles empty string', () => {
expect(reverseString('')).toBe('');
});
test('reverses string with spaces', () => {
expect(reverseString('hello world')).toBe('dlrow olleh');
});
test('reverses palindrome', () => {
expect(reverseString('racecar')).toBe('racecar');
});
});
2. Test Array Filter
- Create filterEven function
- Test with various arrays
- Test empty array
💡 Show Answer
// arrayUtils.ts
export function filterEven(numbers: number[]): number[] {
return numbers.filter(n => n % 2 === 0);
}
// arrayUtils.test.ts
import { filterEven } from './arrayUtils';
describe('filterEven', () => {
test('filters even numbers', () => {
expect(filterEven([1, 2, 3, 4, 5, 6])).toEqual([2, 4, 6]);
});
test('returns empty array when no even numbers', () => {
expect(filterEven([1, 3, 5, 7])).toEqual([]);
});
test('handles empty array', () => {
expect(filterEven([])).toEqual([]);
});
test('handles all even numbers', () => {
expect(filterEven([2, 4, 6, 8])).toEqual([2, 4, 6, 8]);
});
test('handles negative numbers', () => {
expect(filterEven([-2, -1, 0, 1, 2])).toEqual([-2, 0, 2]);
});
});
3. Test Counter Class
- Counter with increment/decrement/reset
- Test all methods
- Test initial state
💡 Show Answer
// counter.ts
export class Counter {
private count = 0;
increment(): void {
this.count++;
}
decrement(): void {
this.count--;
}
reset(): void {
this.count = 0;
}
getValue(): number {
return this.count;
}
}
// counter.test.ts
import { Counter } from './counter';
describe('Counter', () => {
let counter: Counter;
beforeEach(() => {
counter = new Counter();
});
test('starts at zero', () => {
expect(counter.getValue()).toBe(0);
});
test('increments value', () => {
counter.increment();
expect(counter.getValue()).toBe(1);
counter.increment();
expect(counter.getValue()).toBe(2);
});
test('decrements value', () => {
counter.decrement();
expect(counter.getValue()).toBe(-1);
});
test('resets to zero', () => {
counter.increment();
counter.increment();
counter.reset();
expect(counter.getValue()).toBe(0);
});
test('handles multiple operations', () => {
counter.increment();
counter.increment();
counter.decrement();
expect(counter.getValue()).toBe(1);
});
});
4. Test Async fetchData
- Mock async function
- Test success case
- Test error case
💡 Show Answer
// api.ts
export async function fetchData(id: number): Promise<string> {
if (id <= 0) {
throw new Error('Invalid ID');
}
return `Data for ID ${id}`;
}
// api.test.ts
import { fetchData } from './api';
describe('fetchData', () => {
test('returns data for valid ID', async () => {
const result = await fetchData(1);
expect(result).toBe('Data for ID 1');
});
test('throws error for invalid ID', async () => {
await expect(fetchData(0)).rejects.toThrow('Invalid ID');
await expect(fetchData(-1)).rejects.toThrow('Invalid ID');
});
test('resolves with correct data', async () => {
await expect(fetchData(5)).resolves.toBe('Data for ID 5');
});
});
5. Test Validation Function
- Email validator
- Test valid emails
- Test invalid emails
💡 Show Answer
// validator.ts
export function isValidEmail(email: string): boolean {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
// validator.test.ts
import { isValidEmail } from './validator';
describe('isValidEmail', () => {
test('validates correct emails', () => {
expect(isValidEmail('user@example.com')).toBe(true);
expect(isValidEmail('test.user@domain.co.uk')).toBe(true);
expect(isValidEmail('name+tag@example.org')).toBe(true);
});
test('rejects invalid emails', () => {
expect(isValidEmail('invalid')).toBe(false);
expect(isValidEmail('@example.com')).toBe(false);
expect(isValidEmail('user@')).toBe(false);
expect(isValidEmail('user @example.com')).toBe(false);
expect(isValidEmail('')).toBe(false);
});
});
Summary
In this chapter, you learned:
✅ Setting up Jest with TypeScript
✅ Writing basic tests with describe and test
✅ Using Jest matchers
✅ Testing async code
✅ Setup and teardown hooks
✅ Mocking functions and modules
✅ Best practices for testing
Key Takeaways:
- Tests document how code should work
- Test one thing per test
- Use descriptive test names
- Test both success and failure cases
- Mock external dependencies
- Keep tests fast and independent
Testing Best Practices:
- Arrange-Act-Assert pattern
- Test edge cases (empty, null, boundary values)
- Keep tests simple and focused
- Don't test implementation details
- Use beforeEach for common setup
- Mock external dependencies
Common Matchers:
toBe()- Strict equalitytoEqual()- Deep equalitytoBeNull(),toBeUndefined()toBeTruthy(),toBeFalsy()toContain()- Array/string containstoThrow()- Function throws error
Continue to Chapter 18: Advanced Types →