13. Testing TypeScript Code
π§ͺ Master TypeScript testing! Learn Jest, Vitest, type-safe mocks, async testing, generics testing, and E2E with Playwright. Build reliable applications! β¨
What we will learn in this post?
- π Setting Up Testing Environment
- π Writing Type-Safe Tests
- π Mocking and Test Doubles
- π Testing Async Code
- π Testing Generics and Type Guards
- π Code Coverage and Type Coverage
- π E2E Testing with TypeScript
Setting Up Testing Frameworks for TypeScript Projects π οΈ
Testing your TypeScript projects is essential for ensuring code quality. Letβs explore how to set up popular testing frameworks like Jest, Mocha, and Vitest! Modern testing frameworks integrate seamlessly with TypeScriptβs type system for better developer experience.
1. Installing the Framework π¦
First, choose a testing framework. Hereβs how to install Jest:
1
npm install --save-dev jest ts-jest @types/jest
For Mocha:
1
npm install --save-dev mocha @types/mocha ts-node
And for Vitest:
1
npm install --save-dev vitest @types/vitest
2. Configuring TypeScript βοΈ
Using ts-jest
Create a jest.config.js file:
1
2
3
4
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
Using ts-node with Mocha
You can run tests with:
1
mocha -r ts-node/register 'src/**/*.spec.ts'
3. Setting Up Test Scripts π
Add test scripts in your package.json:
1
2
3
4
5
"scripts": {
"test": "jest",
"test:mocha": "mocha -r ts-node/register 'src/**/*.spec.ts'",
"test:vitest": "vitest"
}
4. Running Your Tests π
Now, run your tests with:
1
npm test
Writing Type-Safe Unit Tests in TypeScript π§ͺ
Type-safe unit tests help catch errors early and improve code quality. Hereβs how to write them effectively using Jest or Vitest.
Why Type Safety Matters π
- Catch Errors Early: TypeScript helps identify issues before runtime.
- Better Documentation: Types serve as documentation for your code.
Typing Test Fixtures and Mocks π οΈ
Use interfaces to define your test data:
1
2
3
4
5
6
interface User {
id: number;
name: string;
}
const mockUser: User = { id: 1, name: "Alice" };
Using Assertion Libraries with Type Inference β
With Jest or Vitest, you can use type-safe assertions:
1
2
3
import { expect } from 'vitest';
expect(mockUser.name).toBe("Alice");
Avoiding Type Assertions π«
Instead of using as, rely on TypeScriptβs inference:
1
2
const result = getUser(); // TypeScript infers the type
expect(result).toEqual(mockUser);
Benefits of Type Checking in Tests π
- Improved Reliability: Fewer runtime errors.
- Easier Refactoring: Changes are safer and easier to manage.
Flowchart: Type-Safe Testing Process
flowchart TD
A["βοΈ Write Tests"]:::style1 --> B["π Define Types"]:::style2
B --> C["π Use Mocks"]:::style3
C --> D["π Run Tests"]:::style4
D --> E{"β
Pass?"}:::style5
E -- Yes --> F["π Great!"]:::style2
E -- No --> G["π§ Fix Errors"]:::style1
G --> A
classDef style1 fill:#ff4f81,stroke:#c43e3e,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style2 fill:#6b5bff,stroke:#4a3f6b,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style3 fill:#ffd700,stroke:#d99120,color:#222,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style4 fill:#00bfae,stroke:#005f99,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style5 fill:#ff9800,stroke:#f57c00,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
linkStyle default stroke:#e67e22,stroke-width:3px;
By following these practices, you can ensure your tests are type-safe and maintainable! Happy testing! π
Real-World Example: Testing a User Service π―
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import { describe, it, expect } from 'vitest';
interface User {
id: string;
email: string;
role: 'admin' | 'user';
}
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
class UserService {
constructor(private repo: UserRepository) {}
async getUserById(id: string): Promise<User> {
const user = await this.repo.findById(id);
if (!user) {
throw new Error(`User ${id} not found`);
}
return user;
}
async promoteToAdmin(userId: string): Promise<User> {
const user = await this.getUserById(userId);
user.role = 'admin';
await this.repo.save(user);
return user;
}
}
// Type-safe test with mock repository
describe('UserService', () => {
const mockUser: User = {
id: '123',
email: 'test@example.com',
role: 'user'
};
const mockRepo: UserRepository = {
findById: async (id: string) => id === '123' ? mockUser : null,
save: async (user: User) => { /* mock save */ }
};
it('should get user by id', async () => {
const service = new UserService(mockRepo);
const user = await service.getUserById('123');
// TypeScript ensures type safety
expect(user.email).toBe('test@example.com');
expect(user.role).toBe('user');
});
it('should throw error for non-existent user', async () => {
const service = new UserService(mockRepo);
await expect(service.getUserById('999')).rejects.toThrow('User 999 not found');
});
it('should promote user to admin', async () => {
const service = new UserService(mockRepo);
const promoted = await service.promoteToAdmin('123');
expect(promoted.role).toBe('admin');
});
});
Creating Type-Safe Mocks, Stubs, and Spies in TypeScript
Mocking in TypeScript can be a breeze! Letβs dive into how to create type-safe mocks, stubs, and spies using Jest and libraries like ts-mockito. π
Understanding Mocks, Stubs, and Spies
- Mocks: Fake implementations of functions or objects.
- Stubs: Functions that provide predefined responses.
- Spies: Functions that track calls and parameters.
Using Jest for Mocks
With Jest, you can create mocks easily using jest.fn(). Hereβs how:
1
2
const myMock = jest.fn().mockReturnValue('Hello, World!');
console.log(myMock()); // Outputs: Hello, World!
You can also type your mocks:
1
2
const typedMock: jest.Mock<string> = jest.fn();
typedMock.mockReturnValue('Typed Mock!');
Mocking Interfaces
When you have an interface, you can create a mock implementation:
1
2
3
4
5
6
7
interface Service {
fetchData(): string;
}
const mockService: Service = {
fetchData: jest.fn().mockReturnValue('Mocked Data'),
};
Using ts-mockito
For more complex scenarios, consider using ts-mockito:
1
2
3
4
5
6
7
import { mock, instance } from 'ts-mockito';
const mockedService = mock<Service>();
when(mockedService.fetchData()).thenReturn('Mocked Data with ts-mockito');
const serviceInstance = instance(mockedService);
console.log(serviceInstance.fetchData()); // Outputs: Mocked Data with ts-mockito
Real-World Example: Mocking HTTP Client π―
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import { describe, it, expect, vi } from 'vitest';
interface HttpClient {
get<T>(url: string): Promise<T>;
post<T>(url: string, data: unknown): Promise<T>;
}
interface ApiResponse {
data: any;
status: number;
}
class PaymentService {
constructor(private http: HttpClient) {}
async processPayment(amount: number, cardToken: string): Promise<ApiResponse> {
return this.http.post<ApiResponse>('/api/payments', {
amount,
cardToken
});
}
}
describe('PaymentService with type-safe mocks', () => {
it('should process payment successfully', async () => {
// Create type-safe mock with vi.fn()
const mockHttp: HttpClient = {
get: vi.fn(),
post: vi.fn().mockResolvedValue({
data: { transactionId: 'txn_123' },
status: 200
})
};
const service = new PaymentService(mockHttp);
const result = await service.processPayment(99.99, 'tok_visa');
// Verify mock was called with correct types
expect(mockHttp.post).toHaveBeenCalledWith('/api/payments', {
amount: 99.99,
cardToken: 'tok_visa'
});
expect(result.status).toBe(200);
expect(result.data.transactionId).toBe('txn_123');
});
it('should handle payment failure', async () => {
const mockHttp: HttpClient = {
get: vi.fn(),
post: vi.fn().mockRejectedValue(new Error('Payment declined'))
};
const service = new PaymentService(mockHttp);
await expect(
service.processPayment(99.99, 'tok_invalid')
).rejects.toThrow('Payment declined');
});
});
Testing Async Functions in TypeScript π
Testing async functions and promises in TypeScript can be straightforward with the right approach. Hereβs a friendly guide to help you through it!
Using async/await in Tests π§ͺ
When testing async functions, use async/await for cleaner code:
1
2
3
4
test('fetches data', async () => {
const data = await fetchData();
expect(data).toBeDefined();
});
Handling Rejected Promises β οΈ
To handle rejected promises, use try/catch:
1
2
3
4
5
6
7
test('fetches data with error', async () => {
try {
await fetchDataWithError();
} catch (error) {
expect(error).toBeInstanceOf(Error);
}
});
Typing Async Assertions π
Type your async functions for better clarity:
1
2
3
async function fetchData(): Promise<DataType> {
// implementation
}
Testing Callbacks and Promises π
For callbacks, use done:
1
2
3
4
5
6
test('callback test', (done) => {
callbackFunction((result) => {
expect(result).toBe(true);
done();
});
});
Testing Async Iterators π
Use a loop to test async iterators:
1
2
3
4
5
test('async iterator test', async () => {
for await (const item of asyncIterator()) {
expect(item).toBeDefined();
}
});
Real-World Example: Testing Async Data Fetcher π―
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
interface CacheEntry<T> {
data: T;
timestamp: number;
}
class AsyncDataFetcher<T> {
private cache = new Map<string, CacheEntry<T>>();
private cacheDuration = 5000; // 5 seconds
constructor(private fetcher: (key: string) => Promise<T>) {}
async get(key: string): Promise<T> {
const cached = this.cache.get(key);
if (cached && Date.now() - cached.timestamp < this.cacheDuration) {
return cached.data;
}
const data = await this.fetcher(key);
this.cache.set(key, { data, timestamp: Date.now() });
return data;
}
clearCache(): void {
this.cache.clear();
}
}
describe('AsyncDataFetcher', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should fetch data on first call', async () => {
const mockFetcher = vi.fn().mockResolvedValue({ id: 1, name: 'Product' });
const fetcher = new AsyncDataFetcher(mockFetcher);
const result = await fetcher.get('product-1');
expect(mockFetcher).toHaveBeenCalledTimes(1);
expect(result).toEqual({ id: 1, name: 'Product' });
});
it('should return cached data within cache duration', async () => {
const mockFetcher = vi.fn().mockResolvedValue({ id: 1, name: 'Product' });
const fetcher = new AsyncDataFetcher(mockFetcher);
await fetcher.get('product-1');
await fetcher.get('product-1'); // Should use cache
expect(mockFetcher).toHaveBeenCalledTimes(1); // Only called once
});
it('should refetch after cache expires', async () => {
const mockFetcher = vi.fn()
.mockResolvedValueOnce({ id: 1, name: 'Product v1' })
.mockResolvedValueOnce({ id: 1, name: 'Product v2' });
const fetcher = new AsyncDataFetcher(mockFetcher);
const result1 = await fetcher.get('product-1');
expect(result1.name).toBe('Product v1');
// Fast-forward time by 6 seconds
vi.advanceTimersByTime(6000);
const result2 = await fetcher.get('product-1');
expect(result2.name).toBe('Product v2');
expect(mockFetcher).toHaveBeenCalledTimes(2);
});
it('should handle async errors correctly', async () => {
const mockFetcher = vi.fn().mockRejectedValue(new Error('Network error'));
const fetcher = new AsyncDataFetcher(mockFetcher);
await expect(fetcher.get('product-1')).rejects.toThrow('Network error');
});
});
Testing Generic Functions and Type Safety π οΈ
Understanding Type Guards π
Type guards help us narrow down types in TypeScript. To test them:
- Create test cases for different types.
- Use assertions to check if the type is correctly narrowed.
Example:
1
2
3
4
5
6
7
8
function isString(value: any): value is string {
return typeof value === 'string';
}
const testValue: any = "Hello";
if (isString(testValue)) {
console.log(testValue.toUpperCase()); // Safe to use as string
}
Testing Edge Cases β οΈ
Always test edge cases to ensure your functions handle unexpected inputs:
- Null or undefined values.
- Empty strings or arrays.
- Unexpected types.
Example:
1
2
console.log(isString(null)); // Should return false
console.log(isString(123)); // Should return false
Creating Test Utilities π§ͺ
You can create utilities to check types at runtime:
- Use type assertions to ensure safety.
- Create helper functions for common checks.
Example:
1
2
3
4
5
function assertIsString(value: any): asserts value is string {
if (typeof value !== 'string') {
throw new Error("Not a string!");
}
}
π§ Test Your Knowledge
Which testing framework is specifically built for Vite projects and offers native TypeScript support?
Vitest is designed for Vite projects with native TypeScript support, fast execution, and Jest-compatible API, making it ideal for modern TypeScript applications.
What is the primary benefit of using type-safe mocks in TypeScript tests?
Type-safe mocks ensure that your test doubles match the expected interface types, catching errors during development rather than at runtime, improving test reliability.
Which approach is recommended for testing async functions in TypeScript?
Using async/await syntax in tests provides cleaner, more readable code and better error handling compared to callbacks, making async tests easier to write and maintain.
What does a type guard function return in TypeScript?
Type guards return a type predicate like 'value is Type', which tells TypeScript to narrow the type within the scope where the guard returns true, enabling type-safe operations.
What is the recommended minimum code coverage percentage for production TypeScript applications?
Industry best practice recommends aiming for 80% code coverage as a baseline for production applications, balancing thorough testing with practical development time.
π― Hands-On Assignment: Build a Type-Safe Testing Suite for E-Commerce API π
π Your Mission
Create a comprehensive, production-ready testing suite for an e-commerce shopping cart API using TypeScript. Build type-safe unit tests, integration tests with mocks, async tests for API calls, and measure code coverage. This project mirrors real-world testing scenarios used by companies like Amazon, Shopify, and Stripe.π― Requirements
- Create a
ShoppingCartclass with TypeScript interfaces:addItem(product: Product, quantity: number): voidremoveItem(productId: string): voidcalculateTotal(): numberapplyDiscount(code: string): Promise<number>checkout(): Promise<Order>
- Write unit tests using Vitest or Jest:
- Test adding/removing items with type-safe assertions
- Test total calculation with various scenarios
- Test edge cases (empty cart, invalid quantities)
- Create type-safe mocks for external dependencies:
PaymentServiceinterface with mock implementationInventoryServicefor stock checkingDiscountServicefor coupon validation
- Test async operations:
- Mock async discount code validation
- Test checkout with payment processing
- Handle async errors (payment failures, timeout)
- Achieve 80%+ code coverage and 100% type coverage
π‘ Implementation Hints
- Define interfaces first:
Product,CartItem,Order - Use
vi.fn()orjest.fn()for type-safe mock functions - For async tests, use
async/awaitwithexpect().resolvesorrejects - Create test fixtures with factory functions for reusable test data
- Use
beforeEach()to reset mocks and create fresh instances - Run coverage with:
vitest --coverageorjest --coverage
π Example Input/Output
// Example: ShoppingCart with type-safe tests
import { describe, it, expect, vi, beforeEach } from 'vitest';
interface Product {
id: string;
name: string;
price: number;
}
interface PaymentService {
charge(amount: number): Promise<{ success: boolean; transactionId: string }>;
}
class ShoppingCart {
private items: Map<string, { product: Product; quantity: number }> = new Map();
constructor(private paymentService: PaymentService) {}
addItem(product: Product, quantity: number): void {
const existing = this.items.get(product.id);
if (existing) {
existing.quantity += quantity;
} else {
this.items.set(product.id, { product, quantity });
}
}
calculateTotal(): number {
let total = 0;
for (const { product, quantity } of this.items.values()) {
total += product.price * quantity;
}
return total;
}
async checkout(): Promise<{ success: boolean; total: number }> {
const total = this.calculateTotal();
const result = await this.paymentService.charge(total);
return { success: result.success, total };
}
}
// Type-safe tests
describe('ShoppingCart', () => {
let mockPayment: PaymentService;
let cart: ShoppingCart;
beforeEach(() => {
mockPayment = {
charge: vi.fn().mockResolvedValue({
success: true,
transactionId: 'txn_123'
})
};
cart = new ShoppingCart(mockPayment);
});
it('should add items correctly', () => {
const product: Product = { id: '1', name: 'Laptop', price: 999 };
cart.addItem(product, 2);
expect(cart.calculateTotal()).toBe(1998);
});
it('should checkout successfully', async () => {
const product: Product = { id: '2', name: 'Mouse', price: 25 };
cart.addItem(product, 1);
const result = await cart.checkout();
expect(result.success).toBe(true);
expect(result.total).toBe(25);
expect(mockPayment.charge).toHaveBeenCalledWith(25);
});
});
π Bonus Challenges
- Level 2: Add
applyTax(rate: number): numberwith regional tax calculation tests - Level 3: Implement generic
Cache<T>class with TTL and test it thoroughly - Level 4: Add integration tests using
supertestfor REST API endpoints - Level 5: Write E2E tests with Playwright testing full checkout flow in browser
- Level 6: Implement property-based testing with
fast-checkfor edge cases
π Learning Goals
- Master type-safe testing with TypeScript interfaces and generics π―
- Create realistic mocks for external services and APIs β¨
- Test async operations with proper error handling π
- Achieve high code coverage with meaningful tests π
- Apply testing best practices used in production environments π
- Understand TDD workflow for TypeScript applications π§ͺ
π‘ Pro Tip: This testing pattern is used by major tech companies! Stripe tests payment flows with extensive mocks, Shopify tests checkout with 90%+ coverage, and Netflix uses type-safe tests for their TypeScript microservices. Major frameworks like NestJS, Remix, and tRPC all follow similar testing practices!
Share Your Solution! π¬
Completed the project? Post your code in the comments below! Show us your TypeScript testing mastery! πβ¨
Measuring Code Coverage with Istanbul/nyc for TypeScript π―
Getting Started
To measure code coverage in your TypeScript project, you can use Istanbul (via nyc). Hereβs how to set it up:
- Install Dependencies:
1
npm install --save-dev nyc ts-node typescript
- Configure
nycin yourpackage.json:1 2 3 4 5 6 7
{ "nyc": { "extension": [".ts"], "sourceMap": true, "instrument": true } }
- Run Tests:
1
nyc mocha -r ts-node/register 'test/**/*.spec.ts'
Enabling Source Maps πΊοΈ
Source maps help map your compiled code back to the original TypeScript. Ensure you have "sourceMap": true in your tsconfig.json:
1
2
3
4
5
6
{
"compilerOptions": {
"sourceMap": true,
...
}
}
Measuring Type Completeness π
Use the type-coverage tool to check how many of your variables are typed:
- Install:
1
npm install --save-dev type-coverage
- Run Type Coverage:
1
npx type-coverage
Setting Coverage Goals π―
- Aim for 80% code coverage.
- Strive for 100% type safety.
Improving Type Safety π
- Use strict mode in TypeScript.
- Regularly review and add type annotations.
flowchart TD
A["π Start"]:::style1 --> B{"βοΈ TypeScript Configured?"}:::style2
B -- Yes --> C["π§ͺ Run Tests with NYC"]:::style3
B -- No --> D["π Configure TypeScript"]:::style4
D --> C
C --> E["π Check Coverage Results"]:::style5
E --> F{"β
Coverage Satisfactory?"}:::style2
F -- Yes --> G["π Great! Keep Coding!"]:::style1
F -- No --> H["π§ Improve Tests and Types"]:::style4
H --> C
classDef style1 fill:#ff4f81,stroke:#c43e3e,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style2 fill:#6b5bff,stroke:#4a3f6b,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style3 fill:#ffd700,stroke:#d99120,color:#222,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style4 fill:#00bfae,stroke:#005f99,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style5 fill:#ff9800,stroke:#f57c00,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
linkStyle default stroke:#e67e22,stroke-width:3px;
Happy coding! π
Introduction to End-to-End Testing with TypeScript π
End-to-end (E2E) testing is essential for ensuring your web applications work as expected from start to finish. Using TypeScript with frameworks like Playwright, Cypress, or Puppeteer can enhance your testing experience with strong typing and better tooling.
Why Use TypeScript for E2E Testing? π€
- Type Safety: Catch errors early with TypeScriptβs static typing.
- Better Tooling: Enjoy features like autocompletion and refactoring support.
Key Concepts π
- Page Objects: Organize your tests by creating classes that represent web pages. This keeps your tests clean and maintainable.
- Selectors: Use typed selectors to interact with elements on the page, ensuring you reference them correctly.
- Test Utilities: Create reusable functions to simplify your test code.
Setting Up TypeScript for E2E Tests βοΈ
- Install Dependencies:
1
npm install --save-dev typescript playwright
- Configure TypeScript: Create a
tsconfig.jsonfile:1 2 3 4 5 6 7 8
{ "compilerOptions": { "target": "ES6", "module": "commonjs", "strict": true, "esModuleInterop": true } }
- Write Your First Test:
1 2 3 4 5 6
import { test, expect } from '@playwright/test'; test('homepage has title', async ({ page }) => { await page.goto('https://example.com'); await expect(page).toHaveTitle(/Example Domain/); });
Best Practices π
- Keep Tests Isolated: Each test should run independently.
- Use Descriptive Names: Name your tests clearly to understand their purpose.
- Regularly Refactor: Keep your code clean and maintainable.
Conclusion: Master TypeScript Testing for Reliable Applications π
Testing TypeScript code is essential for building production-ready applications that scale with confidence, leveraging the languageβs type system to catch errors before they reach users. By mastering testing frameworks like Jest and Vitest, creating type-safe mocks, testing async operations thoroughly, achieving high code coverage, and implementing E2E tests with Playwright or Cypress, youβll build robust applications that deliver exceptional reliability and maintainability in real-world production environments.