Lý do cần kiểm thử
Thư viện React+TypeScript của chúng tôi đã hoạt động ổn định một thời gian, chứa các component UI như card, thông báo... Ban đầu do áp lực thời gian, chúng tôi tạm hoãn việc triển khai kiểm thử tự động. Khi đó team nắm rõ logic component nên vẫn làm việc hiệu quả.
Nhưng dần xuất hiện vấn đề khi có thành viên mới hoặc cần cập nhật component cũ. Chúng tôi thường bị tiến thoái lưỡng nan: sửa code có thể phá vỡ chức năng cũ, nhưng không sửa thì không đáp ứng yêu cầu mới. Đặc biệt với các component có nhiều trạng thái tương tác, kiểm thử thủ công trở nên tốn thời gian và không đáng tin. Lúc này, nhu cầu kiểm thử tự động trở nên rõ ràng.
Thiết lập môi trường kiểm thử
Cài đặt dependencies
Bắt đầu với các dependency chính:
- Jest và jsdom cho môi trường chạy test
- Hỗ trợ TypeScript
- Công cụ test React
npm install jest jest-environment-jsdom @types/jest ts-jest @testing-library/react @testing-library/jest-dom @testing-library/user-event --save-dev
Cấu hình Jest
Tạo file jest.config.ts với các điểm quan trọng:
- Xử lý đặc biệt cho TypeScript
- Mô phỏng môi trường trình duyệt
- Quy trình khởi tạo test
- Quy tắc biến đổi file
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
transform: {
"^.+\\.(ts|tsx)$": "ts-jest",
},
testPathIgnorePatterns: ["/node_modules/", "/dist/"],
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
};
export {};
Tạo file jest.setup.ts để import thư viện assertion:
import "@testing-library/jest-dom";
Cấu hình TypeScript
Chỉnh sửa tsconfig.json bao gồm các file test:
{
"include": [
"src",
"jest.config.ts",
"jest.setup.ts",
"__mocks__/**/*.ts"
]
}
Viết test case
Lấy component Notification làm ví dụ, có 2 dạng UI:
- Thông báo với tiêu đề và mô tả
- Thông báo có icon loa, khi click nút đóng sẽ gọi API
Xử lý dependency đặc biệt
Component có 3 loại import cần xử lý:
import './index.less';
import { noticeIcon, closeIcon } from "$src/common/icon";
import request from "$src/request";
1. Xử lý CSS/LESS Jest không thể phân tích file CSS/LESS, có thể mock thành object rỗng:
// jest.config.js
module.exports = {
moduleNameMapper: {
"\\.(less|css)$': '<rootDir>/__mocks__/styleMock.ts",
},
};
// __mocks__/styleMock.ts
module.exports = {};
2. Cấu hình path alias
Với alias như $src, cần map trong Jest config:
// jest.config.js
module.exports = {
moduleNameMapper: {
'^\\$src/(.*)$': '<rootDir>/src/$1',
},
};
3. Mock icon Với tài nguyên tĩnh như icon, có thể mock trực tiếp:
// __tests__/index.test.tsx
jest.mock('$src/common/icon', () => ({
noticeIcon: 'notice-icon-path',
closeIcon: 'close-icon-path',
}));
4. Mock API request
Với module gọi API, có thể chuyển thành Jest mock function:
// __tests__/index.test.tsx
import request from '$src/request';
const mockedRequest = request as jest.MockedFunction<typeof request>;
jest.mock('$src/request', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve({ data: {} })),
}));
Thiết lập khung test cơ bản
Xây dựng cấu trúc test cơ bản:
describe("Component Notification", () => {
const baseProps = {
body: {},
tokenId: "test-token",
urlPrefix: "https://api.example.com",
};
beforeEach(() => {
jest.clearAllMocks();
mockedRequest.mockReset();
});
});
Các kịch bản test chính
1. Test trường hợp biên
Kiểm tra xử lý khi props không hợp lệ:
it("Nên trả về null khi body không hợp lệ", () => {
const { container } = render(<Notification {...baseProps} body={null} />);
expect(container.firstChild).toBeNull();
});
2. Kiểm tra hiển thị ngày Xác nhận:
- Văn bản hiển thị đúng
- Cấu trúc DOM như mong đợi
- Class CSS được áp dụng chính xác
it("Nên hiển thị đúng thông báo ngày", () => {
render(<Notification {...dateProps} />);
expect(screen.getByText("Hôm nay")).toBeInTheDocument();
expect(screen.getByText("2023-06-15")).toBeInTheDocument();
const dateContainer = screen.getByText("Hôm nay").parentElement;
expect(dateContainer).toHaveClass("notice-header-date");
});
3. Test tương tác thông báo
Test thông báo phức tạp hơn, cần xác nhận:
- Hiển thị element ban đầu
- Tải icon đúng cách
- Hành vi khi click đóng
describe("Loại BROADCAST_TYPE", () => {
const broadcastProps = {
...baseProps,
body: {
type: BROADCAST_TYPE,
content: "Nội dung quan trọng",
closeUrl: "/close-notice",
},
};
it("Nên hiển thị nội dung broadcast lúc đầu", () => {
render(<Notification {...broadcastProps} />);
expect(screen.getByText("Nội dung quan trọng")).toBeInTheDocument();
const images = screen.getAllByRole("img");
expect(images[0]).toHaveAttribute("src", "notice-icon-path");
expect(images[1]).toHaveAttribute("src", "close-icon-path");
const broadcastContainer = screen
.getByText("Nội dung quan trọng")
.closest(".notice-header-broadcast");
expect(broadcastContainer).toBeInTheDocument();
});
it("Nên ẩn nội dung khi click nút đóng", () => {
render(<Notification {...broadcastProps} />);
const closeButton = screen.getAllByRole("img")[1].parentElement;
fireEvent.click(closeButton!);
expect(screen.queryByText("Nội dung quan trọng")).not.toBeInTheDocument();
});
});
4. Test kịch bản API Thiết kế test đa chiều:
- Luồng request bình thường
- Không có request
- Xử lý lỗi
- Quản lý trạng thái khi request
describe("Test API request", () => {
const broadcastPropsWithCloseUrl = {
...baseProps,
body: {
type: BROADCAST_TYPE,
content: "Nội dung quan trọng",
closeUrl: "/close-notice",
},
};
it("Nên gửi request khi click đóng", async () => {
mockedRequest.mockResolvedValue({ data: {} });
render(<Notification {...broadcastPropsWithCloseUrl} />);
const closeButton = screen.getAllByRole("img")[1].parentElement;
await act(async () => {
fireEvent.click(closeButton!);
});
expect(request).toHaveBeenCalledWith({
url: "https://api.example.com/close-notice",
method: "post",
data: {},
headers: {
tokenId: "test-token",
},
});
expect(screen.queryByText("Nội dung quan trọng")).not.toBeInTheDocument();
});
});
Thực thi test và coverage
Cấu hình script test trong package.json:
{
"scripts": {
"test": "jest"
}
}
Cấu hình báo cáo coverage trong jest.config.ts:
module.exports = {
collectCoverage: true,
coverageDirectory: "coverage",
coverageReporters: ["text", "html", "lcov", "clover"],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
"./src/components/**/*.tsx": {
branches: 90,
functions: 90,
lines: 90,
statements: 90,
},
},
}