Thực tế của việc kiểm thử: Thoát khỏi bẫy tích hợp
Unit testing một tiện ích toán học cơ bản là việc cực kỳ đơn giản. Tuy nhiên, kỹ thuật phần mềm trong thực tế lại rất rắc rối. Hầu hết code production mà tôi tiếp xúc đều tương tác với Stripe API, cơ sở dữ liệu PostgreSQL hoặc các bucket AWS S3. Nếu bộ test của bạn cố gắng gọi đến một cổng thanh toán thật, sớm muộn gì nó cũng sẽ thất bại do lỗi 401 hoặc mạng chậm. Flaky tests (kiểm thử chập chờn) là kẻ thù của năng suất; tôi đã thấy nhiều đội ngũ mất hàng giờ phát triển chỉ vì một pipeline CI không ổn định.
Mocking và stubbing giải quyết vấn đề này bằng cách mô phỏng các hành vi bên ngoài. Những kỹ thuật này giúp các bài test có tính tất định (deterministic) và tốc độ cực nhanh. Trong các dự án dựa trên ESM hiện đại, Vitest là lựa chọn vượt trội so với Jest. Nó thường cắt giảm 30-50% thời gian thực thi nhờ tận dụng transformation pipeline của Vite. Cảm giác rất mượt mà, đặc biệt khi bộ test của bạn tăng lên hàng trăm file.
Để tránh nhầm lẫn, hãy định nghĩa rõ ràng các công cụ của chúng ta:
- Stubbing: Bạn cung cấp một phản hồi “đóng hộp” (cố định). Nếu code gọi hàm
getExchangeRate(), stub chỉ đơn giản trả về1.2mà không thực hiện bất kỳ phép tính nào. - Mocking: Kỹ thuật này tập trung vào hành vi. Bạn không chỉ cung cấp một giá trị giả; bạn xác minh rằng hàm đó đã được gọi chính xác hai lần với API key chính xác.
Thiết lập: Chuẩn bị môi trường Vitest
Thiết lập Vitest trong một dự án TypeScript mất chưa đầy hai phút. Nếu bạn đã đang sử dụng Vite, việc tích hợp sẽ rất liền mạch. Ngay cả khi chạy độc lập, nó cũng yêu cầu rất ít boilerplate (code mẫu).
Cài đặt gói cốt lõi và UI dashboard để có trải nghiệm phát triển tốt hơn:
npm install -D vitest @vitest/ui @vitest/coverage-v8
Người dùng TypeScript thường gặp lỗi “module not found” khi sử dụng các hàm test toàn cục. Mặc dù bạn có thể bật globals trong file cấu hình, tôi khuyên bạn nên sử dụng explicit imports (import tường minh). Nó giúp code dễ điều hướng hơn và hỗ trợ IntelliSense tốt hơn trong VS Code. Cập nhật file package.json của bạn với các script thiết yếu sau:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}
Triển khai thực tế: Mock và Stub trong hành động
Hãy xem xét một file userService.ts thực hiện lấy thông tin profile và cập nhật cache cục bộ. Chúng ta muốn test logic này mà không thực sự thực hiện các HTTP request.
1. Mocking các Module bên ngoài
Khi sử dụng axios hoặc fetch, bạn nên mock toàn bộ module ở ngay đầu file test. Điều này tạo ra một “bong bóng an toàn” xung quanh môi trường test của bạn.
// userService.ts
import axios from 'axios';
export const getUser = async (id: number) => {
const response = await axios.get(`https://api.myapp.com/users/${id}`);
return response.data;
};
Trong bản test, sử dụng vi.mock() để chặn cuộc gọi mạng. Lưu ý cách vi.mocked() cung cấp đầy đủ tính năng type safety cho các phương thức được mock:
// userService.test.ts
import { describe, it, expect, vi } from 'vitest';
import axios from 'axios';
import { getUser } from './userService';
vi.mock('axios');
describe('getUser', () => {
it('trả về dữ liệu người dùng khi thành công', async () => {
const mockUser = { id: 42, name: 'Alex' };
// TypeScript hiểu rằng axios.get hiện là một mock
vi.mocked(axios.get).mockResolvedValue({ data: mockUser });
const result = await getUser(42);
expect(result).toEqual(mockUser);
expect(axios.get).toHaveBeenCalledWith('https://api.myapp.com/users/42');
});
});
2. Giám sát các phương thức bằng Spy
Không phải lúc nào bạn cũng cần thay thế toàn bộ một module. Đôi khi bạn chỉ cần theo dõi một hàm cụ thể. Ví dụ, bạn có thể muốn đảm bảo một sự kiện analytics được kích hoạt khi người dùng nhấn “Purchase”. vi.spyOn() là công cụ hoàn hảo cho việc này.
it('theo dõi sự kiện thanh toán', () => {
const spy = vi.spyOn(tracker, 'sendEvent');
processCheckout({ total: 99.99 });
expect(spy).toHaveBeenCalledWith('checkout_completed', { amount: 99.99 });
spy.mockRestore(); // Luôn khôi phục để tránh ảnh hưởng đến các bài test khác
});
3. Kiểm soát thời gian
Bạn đang test một token reset mật khẩu sẽ hết hạn sau 15 phút? Đừng thực sự ngồi đợi 15 phút. Hãy sử dụng vi.useFakeTimers() để điều khiển đồng hồ. Điều này cho phép bạn tua nhanh thời gian ngay lập tức.
it('hết hạn phiên làm việc sau 30 phút', () => {
vi.useFakeTimers();
const session = startSession();
// Tua nhanh 31 phút
vi.advanceTimersByTime(31 * 60 * 1000);
expect(session.isValid()).toBe(false);
vi.useRealTimers();
});
Duy trì tính toàn vẹn của Test
Một sai lầm phổ biến là tình trạng “leaky mock” (mock bị rò rỉ). Điều này xảy ra khi một mock từ bài test này ảnh hưởng đến kết quả của bài test tiếp theo. Nó dẫn đến những tình huống gây ức chế khi các bài test chạy riêng lẻ thì vượt qua nhưng lại thất bại khi chạy chung một nhóm.
Tự động hóa việc dọn dẹp
Tôi khuyên bạn nên cấu hình Vitest để tự động reset mọi thứ. Điều này giữ cho các bài test của bạn luôn cô lập và có thể dự đoán được. Thêm các cài đặt này vào file vitest.config.ts:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
clearMocks: true, // Xóa lịch sử gọi hàm
mockReset: true, // Reset về một hàm trống
restoreMocks: true, // Khôi phục triển khai gốc
},
});
Assertion thông minh
Tránh kiểm tra mọi key trong một phản hồi JSON lớn. Nếu một API trả về một đối tượng có 50 trường, nhưng bạn chỉ quan tâm đến email và id, hãy sử dụng asymmetric matchers. Điều này giúp các bài test của bạn bền bỉ hơn trước những thay đổi API không ảnh hưởng đến logic cụ thể của bạn.
expect(apiResponse).toHaveBeenCalledWith(
expect.objectContaining({
email: '[email protected]',
id: expect.any(Number)
})
);
Làm chủ được các pattern này sẽ biến một bộ test mong manh thành một mạng lưới an toàn vững chắc. Bạn có thể refactor các logic phức tạp hoặc nâng cấp dependency với sự tự tin tuyệt đối, vì biết rằng các quy tắc nghiệp vụ cốt lõi của mình đã được bảo vệ khỏi những biến động bên ngoài.

