Skip to main content

Chapter 11: Testing with TypeScript

Authored by syscook.dev

What is Testing with TypeScript?

Testing with TypeScript involves writing and running tests for TypeScript code using testing frameworks and tools that provide type safety, better IDE support, and enhanced debugging capabilities. TypeScript's type system helps catch errors at compile time and provides better test coverage analysis.

Key Concepts:

  • Unit Testing: Testing individual functions and classes
  • Integration Testing: Testing component interactions
  • Type-Safe Testing: Leveraging TypeScript's type system in tests
  • Mocking and Stubbing: Creating test doubles for dependencies
  • Test Coverage: Measuring code coverage with TypeScript
  • Testing Frameworks: Jest, Mocha, Vitest, and other tools

Why Use TypeScript for Testing?

1. Type Safety in Tests

TypeScript provides compile-time type checking for test code, catching errors before tests run.

// Without TypeScript - runtime errors in tests
function add(a, b) {
return a + b;
}

// Test might pass but has runtime issues
test('adds numbers', () => {
expect(add(2, 3)).toBe(5);
expect(add("2", "3")).toBe("23"); // Unexpected behavior
});

// With TypeScript - compile-time type checking
function add(a: number, b: number): number {
return a + b;
}

// Test with type safety
test('adds numbers', () => {
expect(add(2, 3)).toBe(5);
// expect(add("2", "3")).toBe(5); // Compile error: Argument of type 'string' is not assignable to parameter of type 'number'
});

2. Better IDE Support

TypeScript provides autocomplete, refactoring, and navigation in test files.

// TypeScript provides better IDE support in tests
interface User {
id: number;
name: string;
email: string;
}

class UserService {
async getUser(id: number): Promise<User> {
// Implementation
return { id, name: "John", email: "[email protected]" };
}
}

// IDE provides autocomplete and type checking
test('should get user by id', async () => {
const userService = new UserService();
const user = await userService.getUser(1);

// IDE knows user is of type User
expect(user.id).toBe(1);
expect(user.name).toBe("John");
expect(user.email).toBe("[email protected]");
});

How to Use TypeScript for Testing?

1. Setting Up TypeScript Testing

Jest Configuration

// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/tests'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
};

TypeScript Configuration for Testing

// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["jest", "node"]
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}

2. Unit Testing with TypeScript

Basic Unit Tests

// src/math.ts
export class MathUtils {
static add(a: number, b: number): number {
return a + b;
}

static subtract(a: number, b: number): number {
return a - b;
}

static multiply(a: number, b: number): number {
return a * b;
}

static divide(a: number, b: number): number {
if (b === 0) {
throw new Error("Division by zero is not allowed");
}
return a / b;
}
}

// tests/math.test.ts
import { MathUtils } from '../src/math';

describe('MathUtils', () => {
describe('add', () => {
test('should add two positive numbers', () => {
expect(MathUtils.add(2, 3)).toBe(5);
});

test('should add negative numbers', () => {
expect(MathUtils.add(-2, -3)).toBe(-5);
});

test('should add positive and negative numbers', () => {
expect(MathUtils.add(5, -3)).toBe(2);
});
});

describe('divide', () => {
test('should divide two numbers', () => {
expect(MathUtils.divide(10, 2)).toBe(5);
});

test('should throw error when dividing by zero', () => {
expect(() => MathUtils.divide(10, 0)).toThrow("Division by zero is not allowed");
});
});
});

Testing with Interfaces and Types

// src/user.ts
export interface User {
id: number;
name: string;
email: string;
isActive: boolean;
}

export class UserService {
private users: User[] = [];

addUser(user: Omit<User, 'id'>): User {
const newUser: User = {
id: this.users.length + 1,
...user
};
this.users.push(newUser);
return newUser;
}

getUserById(id: number): User | undefined {
return this.users.find(user => user.id === id);
}

getAllUsers(): User[] {
return [...this.users];
}

updateUser(id: number, updates: Partial<Omit<User, 'id'>>): User | undefined {
const userIndex = this.users.findIndex(user => user.id === id);
if (userIndex === -1) {
return undefined;
}

this.users[userIndex] = { ...this.users[userIndex], ...updates };
return this.users[userIndex];
}
}

// tests/user.test.ts
import { UserService, User } from '../src/user';

describe('UserService', () => {
let userService: UserService;

beforeEach(() => {
userService = new UserService();
});

describe('addUser', () => {
test('should add a new user', () => {
const userData = {
name: 'John Doe',
email: '[email protected]',
isActive: true
};

const user = userService.addUser(userData);

expect(user.id).toBe(1);
expect(user.name).toBe(userData.name);
expect(user.email).toBe(userData.email);
expect(user.isActive).toBe(userData.isActive);
});

test('should assign sequential IDs', () => {
const user1 = userService.addUser({
name: 'User 1',
email: '[email protected]',
isActive: true
});

const user2 = userService.addUser({
name: 'User 2',
email: '[email protected]',
isActive: false
});

expect(user1.id).toBe(1);
expect(user2.id).toBe(2);
});
});

describe('getUserById', () => {
test('should return user by ID', () => {
const user = userService.addUser({
name: 'John Doe',
email: '[email protected]',
isActive: true
});

const foundUser = userService.getUserById(user.id);

expect(foundUser).toEqual(user);
});

test('should return undefined for non-existent user', () => {
const foundUser = userService.getUserById(999);
expect(foundUser).toBeUndefined();
});
});

describe('updateUser', () => {
test('should update existing user', () => {
const user = userService.addUser({
name: 'John Doe',
email: '[email protected]',
isActive: true
});

const updatedUser = userService.updateUser(user.id, {
name: 'Jane Doe',
isActive: false
});

expect(updatedUser).toBeDefined();
expect(updatedUser!.name).toBe('Jane Doe');
expect(updatedUser!.email).toBe('[email protected]'); // Unchanged
expect(updatedUser!.isActive).toBe(false);
});

test('should return undefined for non-existent user', () => {
const updatedUser = userService.updateUser(999, { name: 'New Name' });
expect(updatedUser).toBeUndefined();
});
});
});

3. Async Testing with TypeScript

Testing Async Functions

// src/api.ts
export interface ApiResponse<T> {
data: T;
status: number;
message: string;
}

export class ApiClient {
private baseUrl: string;

constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}

async get<T>(endpoint: string): Promise<ApiResponse<T>> {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 100));

return {
data: {} as T,
status: 200,
message: 'Success'
};
}

async post<T>(endpoint: string, data: any): Promise<ApiResponse<T>> {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 100));

return {
data: data as T,
status: 201,
message: 'Created'
};
}
}

// tests/api.test.ts
import { ApiClient, ApiResponse } from '../src/api';

describe('ApiClient', () => {
let apiClient: ApiClient;

beforeEach(() => {
apiClient = new ApiClient('https://api.example.com');
});

describe('get', () => {
test('should make GET request', async () => {
const response = await apiClient.get<{ id: number; name: string }>('/users');

expect(response).toHaveProperty('data');
expect(response).toHaveProperty('status');
expect(response).toHaveProperty('message');
expect(response.status).toBe(200);
expect(response.message).toBe('Success');
});

test('should handle different response types', async () => {
interface User {
id: number;
name: string;
}

const response = await apiClient.get<User[]>('/users');

expect(response.data).toBeDefined();
expect(Array.isArray(response.data)).toBe(true);
});
});

describe('post', () => {
test('should make POST request with data', async () => {
const userData = {
name: 'John Doe',
email: '[email protected]'
};

const response = await apiClient.post<typeof userData>('/users', userData);

expect(response.status).toBe(201);
expect(response.message).toBe('Created');
expect(response.data).toEqual(userData);
});
});
});

Testing Error Handling

// src/error-handling.ts
export class ValidationError extends Error {
constructor(message: string, public field: string) {
super(message);
this.name = 'ValidationError';
}
}

export class ApiError extends Error {
constructor(message: string, public statusCode: number) {
super(message);
this.name = 'ApiError';
}
}

export class UserValidator {
static validateUser(user: any): void {
if (!user.name || typeof user.name !== 'string') {
throw new ValidationError('Name is required', 'name');
}

if (!user.email || typeof user.email !== 'string') {
throw new ValidationError('Email is required', 'email');
}

if (!user.email.includes('@')) {
throw new ValidationError('Invalid email format', 'email');
}
}
}

export class UserService {
async createUser(userData: any): Promise<{ id: number; name: string; email: string }> {
try {
UserValidator.validateUser(userData);

// Simulate API call
await new Promise(resolve => setTimeout(resolve, 100));

return {
id: 1,
name: userData.name,
email: userData.email
};
} catch (error) {
if (error instanceof ValidationError) {
throw error;
}
throw new ApiError('Failed to create user', 500);
}
}
}

// tests/error-handling.test.ts
import { UserValidator, UserService, ValidationError, ApiError } from '../src/error-handling';

describe('UserValidator', () => {
describe('validateUser', () => {
test('should validate correct user data', () => {
const validUser = {
name: 'John Doe',
email: '[email protected]'
};

expect(() => UserValidator.validateUser(validUser)).not.toThrow();
});

test('should throw ValidationError for missing name', () => {
const invalidUser = {
email: '[email protected]'
};

expect(() => UserValidator.validateUser(invalidUser)).toThrow(ValidationError);
expect(() => UserValidator.validateUser(invalidUser)).toThrow('Name is required');
});

test('should throw ValidationError for invalid email', () => {
const invalidUser = {
name: 'John Doe',
email: 'invalid-email'
};

expect(() => UserValidator.validateUser(invalidUser)).toThrow(ValidationError);
expect(() => UserValidator.validateUser(invalidUser)).toThrow('Invalid email format');
});
});
});

describe('UserService', () => {
let userService: UserService;

beforeEach(() => {
userService = new UserService();
});

describe('createUser', () => {
test('should create user with valid data', async () => {
const userData = {
name: 'John Doe',
email: '[email protected]'
};

const user = await userService.createUser(userData);

expect(user).toEqual({
id: 1,
name: 'John Doe',
email: '[email protected]'
});
});

test('should throw ValidationError for invalid data', async () => {
const invalidUserData = {
name: '',
email: 'invalid-email'
};

await expect(userService.createUser(invalidUserData)).rejects.toThrow(ValidationError);
});
});
});

4. Mocking and Stubbing with TypeScript

Jest Mocks with TypeScript

// src/database.ts
export interface Database {
save<T>(data: T): Promise<T>;
findById<T>(id: number): Promise<T | null>;
findAll<T>(): Promise<T[]>;
}

export class UserRepository {
constructor(private database: Database) {}

async saveUser(user: { name: string; email: string }): Promise<{ id: number; name: string; email: string }> {
const savedUser = await this.database.save({
id: Math.random(),
...user
});
return savedUser as { id: number; name: string; email: string };
}

async getUserById(id: number): Promise<{ id: number; name: string; email: string } | null> {
return await this.database.findById<{ id: number; name: string; email: string }>(id);
}
}

// tests/user-repository.test.ts
import { UserRepository, Database } from '../src/database';

// Mock the Database interface
const mockDatabase: jest.Mocked<Database> = {
save: jest.fn(),
findById: jest.fn(),
findAll: jest.fn()
};

describe('UserRepository', () => {
let userRepository: UserRepository;

beforeEach(() => {
userRepository = new UserRepository(mockDatabase);
jest.clearAllMocks();
});

describe('saveUser', () => {
test('should save user to database', async () => {
const userData = {
name: 'John Doe',
email: '[email protected]'
};

const savedUser = {
id: 1,
name: 'John Doe',
email: '[email protected]'
};

mockDatabase.save.mockResolvedValue(savedUser);

const result = await userRepository.saveUser(userData);

expect(mockDatabase.save).toHaveBeenCalledWith({
id: expect.any(Number),
name: userData.name,
email: userData.email
});
expect(result).toEqual(savedUser);
});
});

describe('getUserById', () => {
test('should get user by ID', async () => {
const user = {
id: 1,
name: 'John Doe',
email: '[email protected]'
};

mockDatabase.findById.mockResolvedValue(user);

const result = await userRepository.getUserById(1);

expect(mockDatabase.findById).toHaveBeenCalledWith(1);
expect(result).toEqual(user);
});

test('should return null when user not found', async () => {
mockDatabase.findById.mockResolvedValue(null);

const result = await userRepository.getUserById(999);

expect(result).toBeNull();
});
});
});

Custom Mock Functions

// src/logger.ts
export interface Logger {
info(message: string): void;
error(message: string, error?: Error): void;
warn(message: string): void;
}

export class UserService {
constructor(private logger: Logger) {}

async createUser(userData: { name: string; email: string }): Promise<{ id: number; name: string; email: string }> {
this.logger.info(`Creating user: ${userData.name}`);

try {
// Simulate user creation
const user = {
id: Math.random(),
...userData
};

this.logger.info(`User created successfully: ${user.id}`);
return user;
} catch (error) {
this.logger.error('Failed to create user', error as Error);
throw error;
}
}
}

// tests/user-service.test.ts
import { UserService, Logger } from '../src/logger';

// Create a mock logger
const mockLogger: jest.Mocked<Logger> = {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn()
};

describe('UserService', () => {
let userService: UserService;

beforeEach(() => {
userService = new UserService(mockLogger);
jest.clearAllMocks();
});

describe('createUser', () => {
test('should create user and log success', async () => {
const userData = {
name: 'John Doe',
email: '[email protected]'
};

const user = await userService.createUser(userData);

expect(mockLogger.info).toHaveBeenCalledWith(`Creating user: ${userData.name}`);
expect(mockLogger.info).toHaveBeenCalledWith(`User created successfully: ${user.id}`);
expect(user).toMatchObject(userData);
expect(user.id).toBeDefined();
});
});
});

Practical Examples

1. Testing a Complete Application

// src/app.ts
export interface User {
id: number;
name: string;
email: string;
isActive: boolean;
}

export interface UserRepository {
save(user: Omit<User, 'id'>): Promise<User>;
findById(id: number): Promise<User | null>;
findAll(): Promise<User[]>;
}

export class UserService {
constructor(private userRepository: UserRepository) {}

async createUser(userData: Omit<User, 'id'>): Promise<User> {
if (!userData.name || !userData.email) {
throw new Error('Name and email are required');
}

return await this.userRepository.save(userData);
}

async getUserById(id: number): Promise<User | null> {
if (id <= 0) {
throw new Error('Invalid user ID');
}

return await this.userRepository.findById(id);
}

async getAllUsers(): Promise<User[]> {
return await this.userRepository.findAll();
}
}

// tests/app.test.ts
import { UserService, UserRepository, User } from '../src/app';

describe('UserService', () => {
let userService: UserService;
let mockUserRepository: jest.Mocked<UserRepository>;

beforeEach(() => {
mockUserRepository = {
save: jest.fn(),
findById: jest.fn(),
findAll: jest.fn()
};
userService = new UserService(mockUserRepository);
});

describe('createUser', () => {
test('should create user with valid data', async () => {
const userData = {
name: 'John Doe',
email: '[email protected]',
isActive: true
};

const savedUser: User = {
id: 1,
...userData
};

mockUserRepository.save.mockResolvedValue(savedUser);

const result = await userService.createUser(userData);

expect(mockUserRepository.save).toHaveBeenCalledWith(userData);
expect(result).toEqual(savedUser);
});

test('should throw error for missing name', async () => {
const userData = {
name: '',
email: '[email protected]',
isActive: true
};

await expect(userService.createUser(userData)).rejects.toThrow('Name and email are required');
});

test('should throw error for missing email', async () => {
const userData = {
name: 'John Doe',
email: '',
isActive: true
};

await expect(userService.createUser(userData)).rejects.toThrow('Name and email are required');
});
});

describe('getUserById', () => {
test('should return user by ID', async () => {
const user: User = {
id: 1,
name: 'John Doe',
email: '[email protected]',
isActive: true
};

mockUserRepository.findById.mockResolvedValue(user);

const result = await userService.getUserById(1);

expect(mockUserRepository.findById).toHaveBeenCalledWith(1);
expect(result).toEqual(user);
});

test('should return null when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);

const result = await userService.getUserById(1);

expect(result).toBeNull();
});

test('should throw error for invalid ID', async () => {
await expect(userService.getUserById(0)).rejects.toThrow('Invalid user ID');
await expect(userService.getUserById(-1)).rejects.toThrow('Invalid user ID');
});
});

describe('getAllUsers', () => {
test('should return all users', async () => {
const users: User[] = [
{ id: 1, name: 'John Doe', email: '[email protected]', isActive: true },
{ id: 2, name: 'Jane Smith', email: '[email protected]', isActive: false }
];

mockUserRepository.findAll.mockResolvedValue(users);

const result = await userService.getAllUsers();

expect(mockUserRepository.findAll).toHaveBeenCalled();
expect(result).toEqual(users);
});
});
});

Best Practices

1. Use Type-Safe Mocks

// Good: Type-safe mocks
const mockService: jest.Mocked<MyService> = {
method1: jest.fn(),
method2: jest.fn()
};

// Avoid: Any type mocks
const mockService: any = {
method1: jest.fn(),
method2: jest.fn()
};

2. Test Error Cases

// Good: Test error cases
test('should throw error for invalid input', () => {
expect(() => myFunction(null)).toThrow('Invalid input');
});

3. Use Descriptive Test Names

// Good: Descriptive test names
test('should return user when valid ID is provided', () => {
// Test implementation
});

// Avoid: Vague test names
test('should work', () => {
// Test implementation
});

4. Mock External Dependencies

// Good: Mock external dependencies
jest.mock('../src/external-service', () => ({
ExternalService: jest.fn().mockImplementation(() => ({
method: jest.fn()
}))
}));

Common Pitfalls and Solutions

1. Not Clearing Mocks

// ❌ Problem: Mocks not cleared between tests
beforeEach(() => {
// Missing jest.clearAllMocks()
});

// ✅ Solution: Clear mocks between tests
beforeEach(() => {
jest.clearAllMocks();
});

2. Testing Implementation Details

// ❌ Problem: Testing implementation details
test('should call internal method', () => {
const spy = jest.spyOn(service, 'internalMethod');
service.publicMethod();
expect(spy).toHaveBeenCalled();
});

// ✅ Solution: Test behavior, not implementation
test('should return expected result', () => {
const result = service.publicMethod();
expect(result).toBe(expectedValue);
});

3. Not Testing Error Cases

// ❌ Problem: Only testing happy path
test('should return user', () => {
const user = service.getUser(1);
expect(user).toBeDefined();
});

// ✅ Solution: Test error cases too
test('should return user for valid ID', () => {
const user = service.getUser(1);
expect(user).toBeDefined();
});

test('should throw error for invalid ID', () => {
expect(() => service.getUser(-1)).toThrow();
});

Conclusion

Testing with TypeScript provides powerful tools for creating robust, type-safe test suites. By understanding:

  • What testing with TypeScript involves and its benefits
  • Why it's essential for type safety and better IDE support
  • How to write unit tests, async tests, and use mocking effectively

You can create comprehensive test suites that catch errors at compile time and provide better coverage analysis. TypeScript's type system ensures that your tests are type-safe and that you're testing the right interfaces and contracts.

Next Steps

  • Practice writing unit tests for your TypeScript code
  • Explore integration testing patterns
  • Learn about test coverage analysis
  • Move on to Chapter 12: Build Tools & Configuration

This tutorial is part of the TypeScript Mastery series by syscook.dev