Bài 33.1: Unit testing với Jest trong TypeScript

Bài 33.1: Unit testing với Jest trong TypeScript
Chào mừng bạn trở lại với chuỗi bài viết về Lập trình Web Front-end! Hôm nay, chúng ta sẽ cùng nhau đi sâu vào một khía cạnh cực kỳ quan trọng giúp nâng cao chất lượng mã nguồn của bạn: Unit Testing với Jest trong môi trường TypeScript.
Tại sao Unit Testing lại quan trọng?
Trong thế giới phát triển phần mềm đầy biến động, việc đảm bảo mã nguồn hoạt động đúng như mong đợi là điều thiết yếu. Unit Testing, hay kiểm thử đơn vị, là phương pháp kiểm tra các phần nhỏ nhất có thể tách biệt của ứng dụng (các "đơn vị" như hàm, phương thức, lớp) một cách độc lập.
- Bắt lỗi sớm: Unit test giúp phát hiện lỗi ngay từ giai đoạn phát triển, trước khi chúng kịp lan rộng và gây khó khăn cho việc sửa chữa sau này.
- Tự tin khi Refactor: Khi có một bộ unit test vững chắc, bạn có thể tự tin hơn khi thay đổi cấu trúc mã (refactor) mà không sợ làm hỏng chức năng hiện có.
- Tài liệu sống: Các bài test thường mô tả cách các "đơn vị" mã hoạt động trong các trường hợp khác nhau, đóng vai trò như một dạng tài liệu hữu ích.
- Thiết kế tốt hơn: Việc viết test trước khi viết mã (Test-Driven Development - TDD) hoặc song song với việc viết mã thường dẫn đến thiết kế mô-đun và dễ kiểm thử hơn.
Tại sao lại là Jest và TypeScript?
- Jest: Là một framework kiểm thử JavaScript được phát triển bởi Facebook (nay là Meta), rất phổ biến trong cộng đồng React, nhưng có thể dùng để test hầu hết các ứng dụng JavaScript/TypeScript. Điểm mạnh của Jest là tốc độ, cấu hình đơn giản và đi kèm đầy đủ các công cụ cần thiết (runner, assertion library, mocking).
- TypeScript: Mang lại sự an toàn về kiểu dữ liệu. Khi kết hợp với Jest, bạn có thể viết các bài test mạnh mẽ, tận dụng lợi thế của kiểu dữ liệu để bắt lỗi ngay cả trong mã test của mình.
Sự kết hợp giữa Jest (mạnh mẽ, nhanh chóng) và TypeScript (an toàn kiểu dữ liệu) tạo nên một bộ đôi hoàn hảo để xây dựng các bài unit test đáng tin cậy cho ứng dụng Frontend của bạn.
Bắt đầu với Jest và TypeScript
Để bắt đầu, bạn cần cài đặt các thư viện cần thiết. Giả sử bạn đã có một dự án TypeScript:
npm install --save-dev jest ts-jest @types/jest
# Hoặc với yarn:
# yarn add --dev jest ts-jest @types/jest
jest
: Core framework của Jest.ts-jest
: Bộ chuyển đổi giúp Jest hiểu và chạy mã TypeScript.@types/jest
: Định nghĩa kiểu TypeScript cho Jest, giúp bạn có gợi ý mã và kiểm tra kiểu trong file test.
Tiếp theo, bạn cần cấu hình Jest để làm việc với ts-jest
. Cách đơn giản nhất là thêm cấu hình vào file package.json
:
{
// ... các cấu hình khác của project
"scripts": {
"test": "jest"
},
"jest": {
"preset": "ts-jest"
}
}
Hoặc tạo file jest.config.js
ở thư mục gốc của dự án:
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node', // Hoặc 'jsdom' nếu bạn test mã liên quan đến DOM
};
Bây giờ, bạn đã sẵn sàng viết bài test đầu tiên!
Các Khái niệm Cốt lõi: describe
, it
(hoặc test
), và expect
Bộ ba này là nền tảng của mọi bài test trong Jest:
describe(name, fn)
: Dùng để gom nhóm các bài test liên quan lại với nhau.name
là tên nhóm test (thường là tên hàm, lớp, hoặc module bạn test),fn
là một hàm chứa các bài test cụ thể.it(name, fn)
hoặctest(name, fn)
: Định nghĩa một bài test cụ thể.name
là mô tả bài test này kiểm tra điều gì,fn
là hàm chứa logic kiểm tra thực tế.expect(value)
: Dùng để tạo ra một "assertion" (lời khẳng định). Bạn gọiexpect
với giá trị mà bạn muốn kiểm tra, sau đó sử dụng các "matcher" để so sánh giá trị đó với giá trị mong đợi.
Hãy xem một ví dụ đơn giản:
Giả sử bạn có một hàm TypeScript tính tổng hai số:
// src/math.ts
/**
* Tính tổng hai số nguyên.
* @param a Số hạng thứ nhất.
* @param b Số hạng thứ hai.
* @returns Tổng của a và b.
*/
export function add(a: number, b: number): number {
return a + b;
}
Đây là cách bạn viết unit test cho hàm add
sử dụng Jest và TypeScript:
// src/math.test.ts
import { add } from './math'; // Import hàm cần test
// Gom nhóm các test cho hàm 'add'
describe('add function', () => {
// Bài test cụ thể: kiểm tra khi cộng 2 số dương
it('should return the sum of two positive numbers', () => {
// Khẳng định: mong đợi kết quả của add(2, 3) là 5
expect(add(2, 3)).toBe(5);
});
// Bài test cụ thể: kiểm tra khi có số âm
it('should handle negative numbers correctly', () => {
expect(add(-1, 5)).toBe(4);
});
// Bài test cụ thể: kiểm tra khi có số 0
it('should return the other number when adding zero', () => {
expect(add(10, 0)).toBe(10);
});
});
- Giải thích:
- Chúng ta import hàm
add
từ filemath.ts
. describe('add function', ...)
nhóm tất cả các bài test liên quan đến hàmadd
.- Mỗi khối
it(...)
là một trường hợp test riêng biệt (ví dụ: cộng số dương, cộng số âm). Tên củait
nên mô tả rõ ràng điều gì đang được kiểm tra. expect(add(2, 3))
tạo ra một đối tượngExpect
cho giá trịadd(2, 3)
..toBe(5)
là một "matcher". Nó so sánh giá trị từexpect
với giá trị5
sử dụng so sánh nghiêm ngặt (===
).
- Chúng ta import hàm
Các Matcher Thông dụng trong Jest
Jest cung cấp rất nhiều matcher để bạn so sánh các giá trị theo nhiều cách khác nhau. Dưới đây là một số matcher phổ biến:
.toBe(value)
: So sánh nghiêm ngặt (===
). Tốt nhất cho các giá trị nguyên thủy (số, chuỗi, boolean, null, undefined)..toEqual(value)
: So sánh đệ quy nội dung của các đối tượng hoặc mảng. Rất hữu ích khi test các đối tượng phức tạp..not
: Phủ định matcher tiếp theo. Ví dụ:expect(value).not.toBe(expected)
..toBeNull()
,.toBeUndefined()
,.toBeDefined()
: Kiểm tra giá trị lànull
,undefined
hay được định nghĩa..toBeTruthy()
,.toBeFalsy()
: Kiểm tra giá trị theo ngữ cảnh boolean..toContain(item)
: Kiểm tra xem một item có tồn tại trong mảng hoặc chuỗi không..toHaveLength(number)
: Kiểm tra độ dài của mảng hoặc chuỗi..toMatch(regexp | string)
: Kiểm tra xem một chuỗi có khớp với biểu thức chính quy hoặc chuỗi con không..toThrow(error?)
: Kiểm tra xem một hàm có ném ra lỗi hay không.
Ví dụ về các Matcher khác:
Giả sử bạn có các dữ liệu và hàm sau:
// src/data.ts
export const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' }
];
export function getUserById(id: number) {
const user = users.find(u => u.id === id);
if (!user) {
throw new Error(`User with id ${id} not found.`);
}
return user;
}
Bài test sử dụng nhiều matcher khác nhau:
// src/data.test.ts
import { users, getUserById } from './data';
describe('data module', () => {
it('users array should contain Alice', () => {
// Kiểm tra xem mảng users có chứa một đối tượng có name là 'Alice' không
expect(users).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'Alice' })
]));
// Hoặc đơn giản hơn nếu bạn chỉ cần kiểm tra tên:
const userNames = users.map(u => u.name);
expect(userNames).toContain('Alice');
});
it('users array should have 3 elements', () => {
expect(users).toHaveLength(3);
});
it('getUserById(2) should return Bob', () => {
const bob = getUserById(2);
// Sử dụng toEqual để so sánh nội dung đối tượng
expect(bob).toEqual({ id: 2, name: 'Bob' });
});
it('getUserById(99) should throw an error', () => {
// Sử dụng toThrow để kiểm tra việc ném lỗi
expect(() => getUserById(99)).toThrow('User with id 99 not found.');
// Hoặc chỉ cần kiểm tra có ném lỗi hay không
expect(() => getUserById(99)).toThrow();
});
});
- Giải thích:
expect.arrayContaining
vàexpect.objectContaining
giúp kiểm tra xem một mảng hoặc đối tượng có chứa các phần tử/thuộc tính mong muốn hay không, rất linh hoạt.toHaveLength
kiểm tra kích thước mảng/chuỗi.toEqual
được dùng để so sánh các đối tượng phức tạp hơn.toBe
..toThrow
được dùng để kiểm tra các trường hợp ngoại lệ. Lưu ý bạn cần bọc hàm gọi ném lỗi trong một hàm ẩn danh() => ...
để Jest có thể bắt được lỗi.
Testing Asynchronous Code (Async/Await)
Trong Frontend, làm việc với các thao tác bất đồng bộ (như gọi API) là rất phổ biến. Jest hỗ trợ mạnh mẽ việc test mã bất đồng bộ, đặc biệt là với async/await
.
Giả sử bạn có một hàm bất đồng bộ:
// src/api.ts
/**
* Mô phỏng việc lấy dữ liệu từ API.
* @returns Promise resolves with some data string.
*/
export async function fetchDataFromApi(): Promise<string> {
return new Promise(resolve => {
setTimeout(() => {
resolve('Successfully fetched data');
}, 100); // Giả lập độ trễ mạng 100ms
});
}
/**
* Mô phỏng lỗi khi gọi API.
* @returns Promise rejects with an error.
*/
export async function fetchErrorFromApi(): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error('Failed to fetch data'));
}, 50); // Giả lập lỗi sau 50ms
});
}
Bài test cho mã bất đồng bộ sử dụng async/await
:
// src/api.test.ts
import { fetchDataFromApi, fetchErrorFromApi } from './api';
describe('Async API calls', () => {
// Sử dụng async/await trong bài test
it('fetchDataFromApi should resolve with data', async () => {
// await promise để lấy giá trị resolved
const data = await fetchDataFromApi();
expect(data).toBe('Successfully fetched data');
});
// Kiểm tra Promise bị reject
it('fetchErrorFromApi should reject with an error', async () => {
// Sử dụng try...catch để bắt lỗi reject
try {
await fetchErrorFromApi();
// Nếu await không ném lỗi (promise không bị reject), bài test sẽ thất bại ở đây
// Bạn có thể thêm fail() hoặc expect(true).toBe(false) để làm rõ
// fail('Expected fetchErrorFromApi to throw'); // Cần import fail từ '@jest/globals'
expect(true).toBe(false); // Cách đơn giản để báo thất bại nếu không ném lỗi
} catch (error) {
// Kiểm tra lỗi bị bắt
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('Failed to fetch data');
}
});
// Cách khác kiểm tra Promise resolve/reject bằng matchers .resolves/.rejects
it('fetchDataFromApi should resolve with data using .resolves', () => {
// Trả về Promise để Jest chờ
return expect(fetchDataFromApi()).resolves.toBe('Successfully fetched data');
});
it('fetchErrorFromApi should reject with an error using .rejects', () => {
// Trả về Promise để Jest chờ
return expect(fetchErrorFromApi()).rejects.toThrow('Failed to fetch data');
});
});
- Giải thích:
- Bài test được đánh dấu
async
để có thể sử dụngawait
. - Khi test Promise resolve, bạn có thể
await
kết quả rồi kiểm tra, hoặc sử dụng matcher.resolves
. Cách dùng.resolves
thường ngắn gọn hơn. - Khi test Promise reject, bạn có thể dùng
try...catch
hoặc matcher.rejects
. Matcher.rejects
cũng thường ngắn gọn và rõ ràng hơn. - Quan trọng: Khi sử dụng
.resolves
hoặc.rejects
, bạn phảireturn
Promise đó từ bài test (it
) để Jest biết phải đợi Promise đó hoàn thành.
- Bài test được đánh dấu
Mocking Dependencies
Trong Unit Testing, mục tiêu là kiểm tra một đơn vị mã một cách độc lập. Điều này có nghĩa là bạn cần "giả lập" (mock) các phụ thuộc của đơn vị đó (ví dụ: gọi API khác, truy cập cơ sở dữ liệu, sử dụng hàm từ module khác). Jest có hệ thống mocking rất mạnh mẽ.
Cách đơn giản nhất để mock là sử dụng jest.fn()
, tạo ra một hàm giả lập (mock function):
// src/service.ts
import { fetchDataFromApi } from './api'; // Giả sử fetchDataFromApi là phụ thuộc
export async function processUserData(userId: number): Promise<string> {
// Giả sử hàm này cần gọi API để lấy dữ liệu
const apiData = await fetchDataFromApi();
return `Processed data for user ${userId}: ${apiData.toUpperCase()}`;
}
Bài test cho processUserData
sử dụng mocking fetchDataFromApi
:
// src/service.test.ts
import { processUserData } from './service';
import { fetchDataFromApi } from './api'; // Vẫn import để có thể mock
// Mock toàn bộ module ./api
jest.mock('./api');
// Ép kiểu cho hàm mock để có thể gọi các phương thức của Jest mock function
const mockFetchDataFromApi = fetchDataFromApi as jest.Mock;
describe('processUserData', () => {
// Reset mock trước mỗi test để đảm bảo độc lập
beforeEach(() => {
mockFetchDataFromApi.mockClear(); // Xóa lịch sử gọi
});
it('should process user data correctly using mocked API data', async () => {
// Cấu hình mock function để trả về một giá trị cụ thể cho test này
// .mockResolvedValue là cho Promise resolve
mockFetchDataFromApi.mockResolvedValue('mocked api response');
const result = await processUserData(123);
// Khẳng định:
// 1. Hàm fetchDataFromApi đã được gọi 1 lần
expect(mockFetchDataFromApi).toHaveBeenCalledTimes(1);
// 2. Kết quả xử lý là đúng
expect(result).toBe('Processed data for user 123: MOCKED API RESPONSE');
});
it('should handle API error when processing user data', async () => {
// Cấu hình mock function để ném lỗi (Promise reject)
mockFetchDataFromApi.mockRejectedValue(new Error('Mock API Error'));
// Kiểm tra xem processUserData có ném lỗi khi API lỗi không
await expect(processUserData(456)).rejects.toThrow('Mock API Error');
});
});
- Giải thích:
jest.mock('./api');
chỉ thị cho Jest tạo ra một mock cho module./api
. Mặc định, Jest sẽ tự động tạo ra các mock function cho tất cả các export của module đó.- Chúng ta ép kiểu
fetchDataFromApi
sangjest.Mock
để TypeScript hiểu rằng đây là một mock function và có các phương thức như.mockResolvedValue
,.mockRejectedValue
,.toHaveBeenCalledTimes
, v.v. beforeEach(() => { mockFetchDataFromApi.mockClear(); });
đảm bảo rằng trạng thái của mock (như số lần gọi) được reset trước mỗi bài test, tránh ảnh hưởng lẫn nhau..mockResolvedValue('mocked api response')
: Cấu hình mock function để khi được gọi, nó sẽ trả về một Promise resolve với giá trị'mocked api response'
..mockRejectedValue(new Error('Mock API Error'))
: Cấu hình mock function để khi được gọi, nó sẽ trả về một Promise reject với lỗi được cung cấp.expect(mockFetchDataFromApi).toHaveBeenCalledTimes(1)
: Kiểm tra xem mock function có được gọi chính xác 1 lần trong quá trình chạyprocessUserData
không. Đây là cách kiểm tra "hành vi" (behavior) của mã.
Mocking là một chủ đề rộng, nhưng ví dụ này cho thấy sức mạnh cơ bản của nó trong việc cô lập đơn vị cần test.
Tổ chức File Test
Thông thường, các file test được đặt cùng cấp hoặc trong thư mục __tests__
cạnh file mã nguồn mà chúng test, và có tên theo quy tắc ten_file.test.ts
hoặc ten_file.spec.ts
.
Ví dụ:
src/
├── components/
│ ├── Button.ts
│ ├── Button.test.ts // Test cho Button.ts
│ └── __tests__/ // Hoặc có thể dùng thư mục __tests__
│ └── Button.test.ts
├── utils/
│ ├── formatters.ts
│ └── formatters.test.ts // Test cho formatters.ts
└── index.ts
Cách tổ chức này giúp dễ dàng tìm thấy các bài test tương ứng với mã nguồn và Jest cũng tự động tìm kiếm các file theo quy tắc này.
Chạy Test
Sau khi viết các file test (ví dụ: src/math.test.ts
), bạn chỉ cần mở terminal trong thư mục gốc của dự án và chạy lệnh:
npm test
# Hoặc
# yarn test
Jest sẽ tìm tất cả các file test (mặc định là .test.ts
, .spec.ts
hoặc các file trong thư mục __tests__
), chạy chúng và báo cáo kết quả chi tiết (bao nhiêu test passed, failed, coverage...).
Bạn cũng có thể chạy một file test cụ thể:
npm test src/math.test.ts
Hoặc chạy test theo tên (sử dụng flag -t
hoặc --testNamePattern
):
npm test -t "should handle negative numbers correctly"
Comments