Skip to main content

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 equality
  • toEqual() - Deep equality
  • toBeNull(), toBeUndefined()
  • toBeTruthy(), toBeFalsy()
  • toContain() - Array/string contains
  • toThrow() - Function throws error

Continue to Chapter 18: Advanced Types →