Bài 20.2: Mocking trong TypeScript tests

Bài 20.2: Mocking trong TypeScript tests
Chào mừng trở lại với chuỗi bài viết về Lập trình Web Front-end! Trong bài học hôm nay, chúng ta sẽ lặn sâu vào một kỹ thuật cực kỳ quan trọng khi viết unit tests: Mocking. Nếu bạn đã từng gặp khó khăn khi test các hàm phụ thuộc vào bên ngoài (như API, database, hoặc các module khác), thì mocking chính là người bạn đồng hành mà bạn cần.
Mocking là gì và tại sao chúng ta cần nó?
Khi bạn viết một bài unit test (kiểm thử đơn vị) cho một function, một class, hoặc một module nhỏ nào đó, mục tiêu chính của bạn là kiểm tra xem đơn vị code đó hoạt động đúng như mong đợi trong sự cô lập. Tức là, bạn muốn test chỉ phần code bạn đang quan tâm, mà không bị ảnh hưởng bởi các phần code khác hay các yếu tố bên ngoài.
Tuy nhiên, trong thực tế, code của chúng ta thường có sự phụ thuộc (dependencies). Một function có thể gọi một function khác, một class có thể sử dụng một instance của class khác, module front-end có thể gọi API backend, v.v.
Mocking là kỹ thuật thay thế các phụ thuộc (dependencies) của đơn vị code đang được test bằng các đối tượng "giả mạo" (fake objects). Những đối tượng giả mạo này được gọi là mocks hoặc test doubles (trong ngữ cảnh rộng hơn, bao gồm cả Stubs, Spies, Fakes, v.v.). Mục đích của chúng là:
- Cô lập đơn vị code đang test: Loại bỏ sự phụ thuộc vào code thật của dependency, đảm bảo test chỉ kiểm tra logic của đơn vị hiện tại.
- Kiểm soát hành vi của dependency: Bạn có thể quy định mock sẽ trả về giá trị gì, ném lỗi khi nào, hoặc thực hiện hành động gì cụ thể. Điều này giúp bạn tạo ra các kịch bản test khác nhau (ví dụ: test trường hợp thành công, trường hợp lỗi, trường hợp dữ liệu rỗng...).
- Giúp test chạy nhanh và đáng tin cậy hơn: Thay vì gọi API thật (tốn thời gian, cần mạng) hoặc thao tác với database thật (chậm, cần setup), mock giúp test chạy gần như ngay lập tức và luôn trả về kết quả nhất quán.
- Xác minh sự tương tác: Mocking không chỉ dùng để cung cấp dữ liệu giả, mà còn có thể giúp bạn kiểm tra xem đơn vị code của bạn có gọi đúng dependency với đúng tham số hay không, và gọi bao nhiêu lần.
Tóm lại, mocking là một công cụ mạnh mẽ giúp bạn viết các bài unit test nhanh hơn, ổn định hơn, và kiểm soát chặt chẽ hơn các kịch bản test.
Mocking với Jest trong TypeScript
Jest là một framework testing rất phổ biến, đặc biệt trong hệ sinh thái JavaScript/TypeScript và React/Next.js. Jest cung cấp các API mạnh mẽ và dễ sử dụng để thực hiện mocking. Chúng ta sẽ tập trung vào Jest trong các ví dụ sau.
1. Mocking một Function
Trường hợp phổ biến nhất là khi một function của bạn gọi một function khác, và bạn muốn kiểm tra function đầu tiên mà không cần function thứ hai chạy code thật của nó.
Giả sử bạn có hai file:
// utils.ts
export const formatCurrency = (amount: number): string => {
// Logic format tiền phức tạp ở đây
console.log('Formatting real currency...'); // Chúng ta không muốn dòng này chạy trong test
return `${amount.toFixed(2)} USD`;
};
// payment.ts
import { formatCurrency } from './utils';
export const processPayment = (amount: number): string => {
if (amount <= 0) {
throw new Error('Amount must be positive');
}
const formattedAmount = formatCurrency(amount);
return `Processing payment of ${formattedAmount}`;
};
Chúng ta muốn test processPayment
nhưng lại muốn kiểm soát giá trị trả về của formatCurrency
.
// payment.test.ts
import { processPayment } from './payment';
import * as utils from './utils'; // Quan trọng: Import module chứa function cần mock
describe('processPayment', () => {
// Sử dụng jest.spyOn để theo dõi và thay thế function trong module 'utils'
// spyOn tốt hơn jest.fn() khi bạn muốn giữ lại implementation gốc
// hoặc chỉ muốn theo dõi cuộc gọi mà không thay thế hoàn toàn
const mockFormatCurrency = jest.spyOn(utils, 'formatCurrency');
// Sau mỗi test, khôi phục lại function gốc để các test khác không bị ảnh hưởng bởi mock này
afterEach(() => {
mockFormatCurrency.mockRestore(); // Khôi phục function gốc
});
test('should process payment with positive amount', () => {
// Cấu hình mock để nó trả về giá trị cụ thể
mockFormatCurrency.mockReturnValue('100.00 VND'); // Giả lập kết quả format
const amountToProcess = 100;
const result = processPayment(amountToProcess);
// Kiểm tra xem function mock có được gọi với đúng tham số không
expect(mockFormatCurrency).toHaveBeenCalledWith(amountToProcess);
// Kiểm tra kết quả của function đang test, dựa trên giá trị mock trả về
expect(result).toBe('Processing payment of 100.00 VND');
});
test('should throw error for non-positive amount', () => {
// Test trường hợp này không liên quan đến formatCurrency, nên mock của nó không ảnh hưởng
expect(() => processPayment(0)).toThrow('Amount must be positive');
// Đảm bảo formatCurrency không được gọi trong trường hợp này
expect(mockFormatCurrency).not.toHaveBeenCalled();
});
});
- Giải thích:
import * as utils from './utils';
: Chúng ta import * toàn bộ moduleutils
để có thể truy cập và mock các thành viên bên trong nó.jest.spyOn(utils, 'formatCurrency')
: Hàm này tạo ra một "spy" (người theo dõi) trên functionformatCurrency
trong moduleutils
. Spy này cho phép chúng ta theo dõi xem function có được gọi không, gọi với tham số nào, trả về gì, v.v. Quan trọng là nó cũng cho phép chúng ta thay thế implementation gốc của function đó. Kết quả trả về là mộtJestSpy
.mockFormatCurrency.mockReturnValue('100.00 VND')
: Đây là cách chúng ta cấu hình hành vi của spy/mock. Chúng ta nói rằng, khiformatCurrency
được gọi trong bài test này, nó sẽ không chạy code thật mà thay vào đó sẽ trả về chuỗi'100.00 VND'
.afterEach(() => { mockFormatCurrency.mockRestore(); });
: Việc sử dụngspyOn
thay thế implementation gốc chỉ trong phạm vi bài test hiện tại. Tuy nhiên, để đảm bảo các test case khác trong cùng file test không bị ảnh hưởng bởi mock này, chúng ta nên khôi phục lại function gốc bằngmockRestore()
sau mỗi test.expect(mockFormatCurrency).toHaveBeenCalledWith(amountToProcess);
: Đây là một trong những điểm mạnh của mocking/spying. Chúng ta kiểm tra xem functionformatCurrency
đã được gọi với tham số là giá trị của biếnamountToProcess
.expect(result).toBe(...)
: Chúng ta kiểm tra kết quả trả về củaprocessPayment
. Kết quả này phụ thuộc vào giá trị mock mà chúng ta đã cấu hình ('100.00 VND'
).
2. Mocking một Module (ví dụ: Thư viện HTTP)
Đôi khi, bạn cần mock toàn bộ một module bên ngoài, ví dụ như một thư viện gọi API (như axios
, node-fetch
) hoặc một module cung cấp các hằng số cấu hình.
Giả sử bạn có một service gọi API:
// apiService.ts
import axios from 'axios';
interface User {
id: number;
name: string;
}
export const getUserDetails = async (userId: number): Promise<User> => {
try {
const response = await axios.get(`https://api.example.com/users/${userId}`);
return response.data;
} catch (error) {
console.error('Error fetching user:', error); // Không muốn log này trong test
throw new Error(`Failed to fetch user ${userId}`);
}
};
Bạn muốn test getUserDetails
mà không thực sự gọi ra ngoài internet.
// apiService.test.ts
import { getUserDetails } from './apiService';
import axios from 'axios'; // Import module cần mock
// Thông báo cho Jest biết sẽ mock module 'axios'.
// Điều này sẽ thay thế module axios thật bằng một phiên bản mock tự động của Jest.
jest.mock('axios');
// Ép kiểu (type cast) module axios đã mock để có thể truy cập các hàm mock của Jest
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('getUserDetails', () => {
// Sau mỗi test, xóa bỏ lịch sử gọi của mock để test khác không bị ảnh hưởng
afterEach(() => {
jest.clearAllMocks();
});
test('should fetch user details successfully', async () => {
const mockUserData = { id: 1, name: 'Alice' };
// Cấu hình hành vi của hàm get trong mock axios.
// mockResolvedValue được dùng cho các promise (như kết quả của axios.get)
mockedAxios.get.mockResolvedValue({ data: mockUserData });
const userId = 1;
const result = await getUserDetails(userId);
// Kiểm tra xem axios.get có được gọi với đúng URL không
expect(mockedAxios.get).toHaveBeenCalledWith(`https://api.example.com/users/${userId}`);
// Kiểm tra kết quả trả về của hàm đang test
expect(result).toEqual(mockUserData);
});
test('should throw error if API call fails', async () => {
const apiError = new Error('Network Error');
// Cấu hình mock axios.get để reject promise với một lỗi
mockedAxios.get.mockRejectedValue(apiError);
const userId = 2;
// Sử dụng expects.rejects để kiểm tra lỗi trong hàm async
await expect(getUserDetails(userId)).rejects.toThrow('Failed to fetch user 2');
// Kiểm tra xem axios.get có được gọi không
expect(mockedAxios.get).toHaveBeenCalledWith(`https://api.example.com/users/${userId}`);
});
});
- Giải thích:
jest.mock('axios');
: Lệnh này bảo Jest rằng hãy thay thế moduleaxios
thật bằng một phiên bản mock. Jest sẽ tự động tạo ra một mock cho module này, bao gồm các hàm mock cho các phương thức export củaaxios
(nhưget
,post
, v.v.).const mockedAxios = axios as jest.Mocked<typeof axios>;
: Jest mock các module một cách "động", và TypeScript không biết điều đó theo mặc định. Việc ép kiểu này giúp TypeScript hiểu rằngaxios
trong file test này giờ đây là một mock và có các phương thức nhưmockResolvedValue
,toHaveBeenCalled
, v.v.mockedAxios.get.mockResolvedValue({ data: mockUserData });
: Chúng ta truy cập vào hàmget
trong mockaxios
và cấu hình nó.mockResolvedValue
được dùng khi hàm mock trả về một Promise thành công. Chúng ta giả lập kết quả trả về có cấu trúc giống như response thật củaaxios
({ data: ... }
).mockedAxios.get.mockRejectedValue(apiError);
: Để test trường hợp lỗi, chúng ta cấu hìnhget
để trả về một Promise bị từ chối (rejected) với lỗi mà chúng ta định nghĩa.afterEach(() => { jest.clearAllMocks(); });
: Lệnh này sẽ xóa bỏ lịch sử gọi và cấu hình của tất cả các mocks trong file test này sau mỗi test case. Điều này quan trọng để đảm bảo các test độc lập với nhau.jest.restoreAllMocks()
sẽ khôi phục implementation gốc cho các mocks được tạo bằngjest.spyOn
hoặcjest.mock
với factory function.jest.clearAllMocks()
chỉ xóa lịch sử gọi. Tùy trường hợp mà bạn chọn lệnh phù hợp. Vớijest.mock
module,clearAllMocks
thường đủ.expect(mockedAxios.get).toHaveBeenCalledWith(...)
: Tương tự ví dụ trước, chúng ta xác minh rằng hàmget
của mockaxios
đã được gọi với đúng URL.await expect(getUserDetails(userId)).rejects.toThrow(...)
: Đối với async function dự kiến sẽ ném lỗi, chúng ta sử dụng cú phápawait expect(...).rejects.toThrow(...)
.
3. Mocking một Class
Mocking class phức tạp hơn một chút vì bạn cần mock cả constructor và các phương thức của instance.
Giả sử bạn có một class UserService
phụ thuộc vào một class DatabaseService
:
// databaseService.ts
export class DatabaseService {
private connection: any; // Giả lập kết nối DB
constructor(connectionString: string) {
// Logic kết nối DB thật ở đây
console.log(`Connecting to real DB with: ${connectionString}`); // Không muốn log này trong test
this.connection = { query: (sql: string) => ({ id: 1, name: 'Real User' }) }; // Giả lập query
}
async getUserFromDb(id: number): Promise<{ id: number; name: string }> {
console.log(`Fetching user ${id} from real DB...`); // Không muốn log này
// Thực hiện query DB thật...
return { id, name: `User ${id}` }; // Kết quả DB thật
}
}
// userService.ts
import { DatabaseService } from './databaseService';
export class UserService {
private dbService: DatabaseService;
constructor(dbConnectionString: string) {
// Class này tạo ra instance của DatabaseService
this.dbService = new DatabaseService(dbConnectionString);
}
async getUserById(userId: number) {
// Gọi phương thức của instance DatabaseService
return this.dbService.getUserFromDb(userId);
}
}
Chúng ta muốn test UserService
mà không cần kết nối đến database thật.
// userService.test.ts
import { UserService } from './userService';
import { DatabaseService } from './databaseService'; // Import class cần mock
// Mock toàn bộ module chứa DatabaseService
jest.mock('./databaseService');
// Ép kiểu mock để truy cập constructor và các phương thức mock
// Đây là mock của chính class (constructor)
const MockedDatabaseService = DatabaseService as jest.Mock<typeof DatabaseService>;
describe('UserService', () => {
// Khai báo biến cho instance mock của DatabaseService
let mockDbServiceInstance: jest.Mocked<DatabaseService>;
beforeEach(() => {
// Tạo ra một object sẽ đóng vai trò là instance của DatabaseService mock
// Chỉ cần định nghĩa các phương thức mà UserService sẽ gọi
mockDbServiceInstance = {
getUserFromDb: jest.fn(), // Mock phương thức getUserFromDb
// Nếu UserService gọi constructor với tham số, mock constructor cần xử lý
// Nhưng trong ví dụ này, UserService chỉ tạo instance, không gọi phương thức nào của constructor sau khi tạo
} as jest.Mocked<DatabaseService>; // Ép kiểu để Jest biết đây là mock instance
// Cấu hình mock constructor của DatabaseService
// Nói với Jest: khi `new DatabaseService(...)` được gọi, hãy trả về `mockDbServiceInstance` này
MockedDatabaseService.mockImplementation(() => mockDbServiceInstance);
});
afterEach(() => {
// Xóa bỏ lịch sử gọi của tất cả các mocks
jest.clearAllMocks();
});
test('should fetch user using mocked DatabaseService', async () => {
const mockUserData = { id: 5, name: 'Mocked User' };
const userIdToFetch = 5;
const dbConnectionString = 'mock-db-connection';
// Cấu hình hành vi của phương thức getUserFromDb trên mock instance
mockDbServiceInstance.getUserFromDb.mockResolvedValue(mockUserData);
// Khởi tạo UserService. Nó sẽ gọi mock constructor của DatabaseService,
// và constructor mock đó sẽ trả về mockDbServiceInstance của chúng ta.
const userService = new UserService(dbConnectionString);
// Gọi phương thức của UserService
const result = await userService.getUserById(userIdToFetch);
// Kiểm tra xem constructor của DatabaseService có được gọi với đúng tham số không
expect(MockedDatabaseService).toHaveBeenCalledWith(dbConnectionString);
expect(MockedDatabaseService).toHaveBeenCalledTimes(1); // Đảm bảo chỉ gọi 1 lần
// Kiểm tra xem phương thức getUserFromDb của mock instance có được gọi không
expect(mockDbServiceInstance.getUserFromDb).toHaveBeenCalledWith(userIdToFetch);
expect(mockDbServiceInstance.getUserFromDb).toHaveBeenCalledTimes(1);
// Kiểm tra kết quả trả về của UserService
expect(result).toEqual(mockUserData);
});
test('should handle error from mocked DatabaseService', async () => {
const dbConnectionString = 'mock-db-connection';
const userIdToFetch = 6;
const dbError = new Error('DB connection failed');
// Cấu hình phương thức mock để ném lỗi
mockDbServiceInstance.getUserFromDb.mockRejectedValue(dbError);
const userService = new UserService(dbConnectionString);
// Kiểm tra xem UserService có ném lỗi khi DBService ném lỗi không
await expect(userService.getUserById(userIdToFetch)).rejects.toThrow(dbError);
expect(MockedDatabaseService).toHaveBeenCalledWith(dbConnectionString);
expect(mockDbServiceInstance.getUserFromDb).toHaveBeenCalledWith(userIdToFetch);
});
});
- Giải thích:
jest.mock('./databaseService');
: Báo Jest mock toàn bộ module. Khi làm việc với class, Jest sẽ mock cả constructor của class đó.const MockedDatabaseService = DatabaseService as jest.Mock<typeof DatabaseService>;
: Ép kiểu mock constructor.jest.Mock<typeof DatabaseService>
cho phép chúng ta gọi các phương thức mock trên constructor, ví dụ nhưmockImplementation
.let mockDbServiceInstance: jest.Mocked<DatabaseService>;
: Khai báo biến để giữ instance mock. Đây là object mà chúng ta sẽ định nghĩa các phương thức mock (getUserFromDb
).jest.Mocked<DatabaseService>
giúp TypeScript nhận biết các phương thức của instance này có thể được mock.beforeEach(...)
: Block này chạy trước mỗi test case. Đây là nơi lý tưởng để setup các mocks cần thiết cho instance.mockDbServiceInstance = { getUserFromDb: jest.fn(), ... }
: Chúng ta tạo một object đơn giản. Chỉ cần thêm vào đó các phương thức mà class đang test (UserService
) gọi từ dependency (DatabaseService
). Sử dụngjest.fn()
để biến chúng thành các mock function có thể theo dõi và cấu hình.MockedDatabaseService.mockImplementation(() => mockDbServiceInstance);
: Đây là điểm mấu chốt khi mocking class. Chúng ta cấu hình mock constructor (MockedDatabaseService
). Khinew DatabaseService(...)
được gọi trong code thật (ở đây là trong constructor củaUserService
), thay vì chạy constructor thật, Jest sẽ gọi hàm mà chúng ta truyền vàomockImplementation
. Hàm này sẽ trả vềmockDbServiceInstance
mà chúng ta đã tạo.mockDbServiceInstance.getUserFromDb.mockResolvedValue(mockUserData);
: Chúng ta cấu hình hành vi của phương thứcgetUserFromDb
trên instance mock mà chúng ta đã tạo.- Các
expect
s kiểm tra xem constructorDatabaseService
có được gọi với đúng tham số không (toHaveBeenCalledWith
), và phương thứcgetUserFromDb
trên instance mock có được gọi với đúng tham số không.
Các loại Mock/Test Double khác (Nói nhanh)
Mặc dù chúng ta thường dùng từ "mocking" chung chung, trong lý thuyết kiểm thử, có các loại "test double" khác:
- Dummy Objects: Objects được truyền qua lại nhưng không bao giờ được sử dụng. Chỉ dùng để điền vào danh sách tham số.
- Fakes: Objects có implementation thật nhưng đơn giản hơn (ví dụ: database fake trong bộ nhớ).
- Stubs: Cung cấp các giá trị trả về "có kịch bản" cho các cuộc gọi trong quá trình test. Trọng tâm là state-based testing (kiểm tra trạng thái sau khi hành động). Mocking thường bao gồm cả chức năng của stub.
- Spies: Bao bọc quanh một object/function thật và ghi lại thông tin về cách nó được gọi (tham số, số lần, giá trị trả về). Bạn có thể kiểm tra thông tin này sau khi code chạy.
jest.spyOn
tạo ra một spy, nhưng bạn có thể cấu hình nó để hoạt động như một stub hoặc mock. - Mocks: Các object được cấu hình trước với các kỳ vọng (expectations) về cách chúng sẽ được gọi. Trọng tâm là interaction-based testing (kiểm tra sự tương tác giữa các đối tượng). Bạn thiết lập kỳ vọng trước, chạy code, rồi kiểm tra xem kỳ vọng có được đáp ứng không. Các ví dụ trên với
mockReturnValue
,mockResolvedValue
,toHaveBeenCalled
kết hợp cả chức năng của stub và mock.
Trong thực tế với Jest, ranh giới giữa Stub, Spy và Mock khá mờ nhạt. jest.fn()
tạo ra một mock function đơn giản, còn jest.spyOn
tạo ra một spy có thể hoạt động như mock/stub.
Lời khuyên khi Mocking
- Đừng Over-Mock: Chỉ mock những thứ thực sự cần thiết (phụ thuộc ngoài, chậm, không ổn định). Mocking quá nhiều làm test khó đọc, khó bảo trì và có thể che giấu bug thật.
- Mock Behaviour, Không phải Implementation: Cố gắng mock giao diện hoặc hành vi dự kiến của dependency, chứ không phải chi tiết cài đặt bên trong của nó. Nếu bạn mock chi tiết cài đặt, test của bạn sẽ dễ bị hỏng khi code thật được refactor, ngay cả khi logic vẫn đúng.
- Giữ Mocks Đơn Giản: Mock càng phức tạp, khả năng mock đó có bug càng cao.
- Test Interaction VÀ State: Đừng chỉ kiểm tra xem mock có được gọi không (
toHaveBeenCalled
). Hãy kiểm tra cả kết quả (state) của đơn vị code đang test dựa trên giá trị mà mock trả về.
Mocking là một kỹ năng không thể thiếu khi viết unit tests hiệu quả trong TypeScript (và bất kỳ ngôn ngữ nào khác). Nó giúp chúng ta kiểm soát môi trường test, cô lập code và xây dựng bộ test đáng tin cậy.
Chúc bạn thành công với việc áp dụng mocking vào các bài test của mình!
Comments