Introduction: Why Test TypeScript Code?
TypeScript is a powerful tool that adds static typing to JavaScript, allowing you to catch errors at compile time. However, even the strictest type system does not guarantee that your code will work correctly in real-world conditions. Logical errors, incorrect handling of edge cases, and module integration issues remain outside the compiler's scope. This is where testing comes to the rescue.
Testing in TypeScript is not just about checking functions. It is a comprehensive process that includes three key levels: Unit, Integration, and E2E. Each solves its own task, and together they form the "testing pyramid" that ensures application reliability.
In this article, we will break down how to write tests for TypeScript at all three levels using modern tools: Jest, Supertest, and Playwright. You will learn what errors these tests help prevent and how to implement them in your project.
Unit Testing: Checking Isolated Modules
Unit tests are the foundation of the pyramid. They test individual functions, classes, or methods in isolation from external dependencies (databases, APIs, file systems). The main goal is to ensure that every "brick" of your application works correctly.
Tools and Setup
The most popular framework for unit testing in TypeScript is Jest. It provides built-in TypeScript support via ts-jest or @swc/jest.
Installing Jest in a project:
npm install --save-dev jest @types/jest ts-jestnpx ts-jest config:initUnit Test Example
Consider a simple function for calculating a discount:
// discount.tsexport function applyDiscount(price: number, discountPercent: number): number { if (discountPercent < 0 || discountPercent > 100) { throw new Error('Discount percentage must be between 0 and 100'); } return price - (price * discountPercent) / 100;}Let's write unit tests covering the main scenarios:
// discount.test.tsimport { applyDiscount } from './discount';
describe('applyDiscount', () => { it('should correctly apply a 20% discount', () => { expect(applyDiscount(100, 20)).toBe(80); });
it('should return the original price with a 0% discount', () => { expect(applyDiscount(50, 0)).toBe(50); });
it('should return 0 with a 100% discount', () => { expect(applyDiscount(200, 100)).toBe(0); });
it('should throw an error with a negative discount', () => { expect(() => applyDiscount(100, -10)).toThrow('Discount percentage must be between 0 and 100'); });
it('should throw an error with a discount greater than 100%', () => { expect(() => applyDiscount(100, 150)).toThrow('Discount percentage must be between 0 and 100'); });});Important: Unit tests should not access external resources. Instead, use mocks to substitute dependencies, for example, using jest.mock().
Integration Testing: Interaction Between Modules
Integration tests check how different parts of the system work together. This could be the interaction between a service and a database, a controller and a repository, or between microservices. Unlike unit tests, we do not completely isolate components here.
Example: Testing a REST API
Suppose we have an Express application with one endpoint. For testing HTTP requests, we use Supertest.
// userController.tsimport { Router, Request, Response } from 'express';
interface User { id: number; name: string;}
const users: User[] = [{ id: 1, name: 'Alice' }];