Hướng Dẫn Testing Vitest: Từ Zero Đến Tự Tin Production
Unit tests, integration tests, component tests với Testing Library, chiến lược mocking, cấu hình coverage, và triết lý testing thực sự tạo ra phần mềm tốt hơn.
Hầu hết các team viết test giống như cách mọi người tập thể dục: họ biết mình nên làm, cảm thấy có lỗi khi không làm, và khi cuối cùng cũng bắt tay vào, họ cố quá sức vào ngày đầu tiên rồi bỏ cuộc vào thứ Sáu. Codebase cuối cùng chỉ còn lại vài snapshot test nông cạn vỡ mỗi lần ai đó thay đổi một CSS class, vài integration test mà không ai tin tưởng, và một badge coverage trong README đang nói dối.
Tôi đã ở cả hai phía. Tôi đã ship dự án với zero test và đổ mồ hôi lạnh qua mỗi lần deploy. Tôi cũng từng ở trong các team đuổi theo 100% coverage và dành nhiều thời gian bảo trì test hơn là viết tính năng. Không cách nào hoạt động. Cái hoạt động là viết đúng test, ở đúng chỗ, với đúng công cụ.
Vitest đã thay đổi cách tôi nghĩ về testing trong JavaScript. Không phải vì nó phát minh ra khái niệm mới — các nguyên tắc cơ bản không thay đổi kể từ khi Kent Beck viết về chúng cách đây nhiều thập kỷ. Mà vì nó loại bỏ đủ ma sát để việc viết test không còn cảm giác như một việc vặt nữa mà bắt đầu cảm giác như một phần của vòng lặp phát triển. Khi test runner của bạn nhanh như dev server và dùng cùng config, mọi lý do biện hộ đều biến mất.
Bài viết này là tất cả những gì tôi biết về testing với Vitest, từ thiết lập ban đầu đến triết lý khiến tất cả trở nên đáng giá.
Tại Sao Chọn Vitest Thay Vì Jest#
Nếu bạn đã dùng Jest, bạn đã biết hầu hết API của Vitest. Điều đó là có chủ đích — Vitest tương thích với Jest ở cấp độ API. describe, it, expect, beforeEach, vi.fn() — tất cả đều hoạt động. Vậy tại sao phải chuyển?
Hỗ Trợ ESM Gốc#
Jest được xây dựng cho CommonJS. Nó có thể xử lý ESM, nhưng đòi hỏi cấu hình, cờ thử nghiệm, và thỉnh thoảng phải cầu nguyện. Nếu bạn đang dùng cú pháp import/export (tức là mọi thứ hiện đại), có lẽ bạn đã từng chiến đấu với pipeline transform của Jest.
Vitest chạy trên Vite. Vite hiểu ESM một cách gốc. Không có bước transform nào cho mã nguồn của bạn — nó chỉ đơn giản là hoạt động. Điều này quan trọng hơn bạn nghĩ. Một nửa số vấn đề Jest tôi đã debug qua nhiều năm đều bắt nguồn từ module resolution: SyntaxError: Cannot use import statement outside a module, hoặc mock không hoạt động vì module đã được cache ở một format khác.
Cùng Config Với Dev Server#
Nếu dự án của bạn dùng Vite (và nếu bạn đang xây dựng ứng dụng React, Vue, hoặc Svelte năm 2026, nhiều khả năng là có), Vitest tự động đọc vite.config.ts của bạn. Path alias, plugin, và biến môi trường đều hoạt động trong test mà không cần cấu hình thêm. Với Jest, bạn duy trì một cấu hình song song phải đồng bộ với thiết lập bundler. Mỗi lần bạn thêm một path alias trong vite.config.ts, bạn phải nhớ thêm moduleNameMapper tương ứng trong jest.config.ts. Đó là điều nhỏ, nhưng những điều nhỏ tích lũy theo thời gian.
Tốc Độ#
Vitest nhanh. Nhanh một cách có ý nghĩa. Không phải "tiết kiệm hai giây" nhanh — mà là "thay đổi cách bạn làm việc" nhanh. Nó sử dụng module graph của Vite để hiểu test nào bị ảnh hưởng bởi thay đổi file và chỉ chạy những test đó. Watch mode của nó sử dụng cùng hạ tầng HMR khiến dev server của Vite cảm giác tức thì.
Trên một dự án với hơn 400 test, chuyển từ Jest sang Vitest cắt giảm vòng lặp phản hồi watch-mode từ ~4 giây xuống dưới 500ms. Đó là sự khác biệt giữa "tôi sẽ đợi test pass" và "tôi sẽ liếc terminal trong khi ngón tay vẫn còn trên bàn phím."
Benchmarking Tích Hợp Sẵn#
Vitest bao gồm bench() sẵn có cho performance testing. Không cần thư viện riêng:
import { bench, describe } from "vitest";
describe("string concatenation", () => {
bench("template literals", () => {
const name = "world";
const _result = `hello ${name}`;
});
bench("string concat", () => {
const name = "world";
const _result = "hello " + name;
});
});Chạy với vitest bench. Đó không phải sự kiện chính, nhưng thật tuyệt khi có performance testing trong cùng toolchain mà không cần cài benchmark.js và thiết lập runner riêng.
Thiết Lập#
Cài Đặt#
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdomCấu Hình#
Tạo vitest.config.ts ở thư mục gốc dự án:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
alias: {
"@": path.resolve(__dirname, "./src"),
},
css: false,
},
});Hoặc, nếu bạn đã có vite.config.ts, bạn có thể mở rộng nó:
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": "/src",
},
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
});Quyết Định globals: true#
Khi globals là true, bạn không cần import describe, it, expect, beforeEach, v.v. — chúng có sẵn ở mọi nơi, giống như trong Jest. Khi false, bạn import chúng một cách tường minh:
// globals: false
import { describe, it, expect } from "vitest";
describe("math", () => {
it("adds numbers", () => {
expect(1 + 1).toBe(2);
});
});Tôi dùng globals: true vì nó giảm nhiễu thị giác và phù hợp với kỳ vọng của hầu hết lập trình viên. Nếu bạn ở trong team coi trọng import tường minh, đặt nó thành false — không có đáp án sai ở đây.
Nếu bạn dùng globals: true, thêm các type của Vitest vào tsconfig.json để TypeScript nhận diện chúng:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}Môi Trường: jsdom vs happy-dom vs node#
Vitest cho phép bạn chọn DOM implementation theo từng test hoặc toàn cục:
node— Không có DOM. Cho logic thuần, tiện ích, API route, và bất cứ thứ gì không chạm đến trình duyệt.jsdom— Tiêu chuẩn. DOM implementation đầy đủ. Nặng hơn nhưng hoàn thiện hơn.happy-dom— Nhẹ hơn và nhanh hơn jsdom nhưng kém hoàn thiện. Một số edge case (nhưRange,Selection, hoặcIntersectionObserver) có thể không hoạt động.
Tôi mặc định dùng jsdom toàn cục và ghi đè theo từng file khi cần node:
// src/lib/utils.test.ts
// @vitest-environment node
import { formatDate, slugify } from "./utils";
describe("slugify", () => {
it("converts spaces to hyphens", () => {
expect(slugify("hello world")).toBe("hello-world");
});
});File Thiết Lập#
File setup chạy trước mọi file test. Đây là nơi bạn cấu hình Testing Library matcher và các mock toàn cục:
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
// Mock IntersectionObserver — jsdom không implement nó
class MockIntersectionObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
value: MockIntersectionObserver,
});
// Mock window.matchMedia — cần thiết cho component responsive
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});Import @testing-library/jest-dom/vitest cho bạn các matcher như toBeInTheDocument(), toHaveClass(), toBeVisible(), và nhiều hơn nữa. Những thứ này làm các assertion trên DOM element dễ đọc và biểu đạt.
Viết Test Tốt#
Mẫu AAA#
Mỗi test đều theo cùng cấu trúc: Arrange (Sắp xếp), Act (Hành động), Assert (Khẳng định). Ngay cả khi bạn không viết comment rõ ràng, cấu trúc vẫn nên nhìn thấy được:
it("calculates total with tax", () => {
// Arrange
const items = [
{ name: "Widget", price: 10 },
{ name: "Gadget", price: 20 },
];
const taxRate = 0.08;
// Act
const total = calculateTotal(items, taxRate);
// Assert
expect(total).toBe(32.4);
});Khi tôi thấy một test trộn lẫn sắp xếp, hành động, và khẳng định vào một chuỗi method call, tôi biết nó sẽ khó hiểu khi fail. Giữ ba giai đoạn riêng biệt về mặt thị giác ngay cả khi bạn không thêm comment.
Đặt Tên Test#
Có hai trường phái: it('should calculate total with tax') và it('calculates total with tax'). Tiền tố "should" dài dòng mà không thêm thông tin. Khi test fail, bạn sẽ thấy:
FAIL ✕ calculates total with tax
Đó đã là một câu hoàn chỉnh rồi. Thêm "should" chỉ thêm nhiễu. Tôi thích dạng trực tiếp: it('renders loading state'), it('rejects invalid email'), it('returns empty array when no matches found').
Cho khối describe, dùng tên của đơn vị đang test:
describe("calculateTotal", () => {
it("sums item prices", () => { /* ... */ });
it("applies tax rate", () => { /* ... */ });
it("returns 0 for empty array", () => { /* ... */ });
it("handles negative prices", () => { /* ... */ });
});Đọc to lên: "calculateTotal sums item prices." "calculateTotal applies tax rate." Nếu câu đó hợp lý, tên đó hợp lý.
Một Assertion Mỗi Test vs Nhóm Thực Tế#
Quy tắc thuần túy nói một assertion mỗi test. Quy tắc thực tế nói: một khái niệm mỗi test. Đây là hai thứ khác nhau.
// Cái này ổn — một khái niệm, nhiều assertion
it("formats user display name", () => {
const user = { firstName: "John", lastName: "Doe", title: "Dr." };
const result = formatDisplayName(user);
expect(result).toContain("John");
expect(result).toContain("Doe");
expect(result).toStartWith("Dr.");
});
// Cái này không ổn — nhiều khái niệm trong một test
it("handles user operations", () => {
const user = createUser("John");
expect(user.id).toBeDefined();
const updated = updateUser(user.id, { name: "Jane" });
expect(updated.name).toBe("Jane");
deleteUser(user.id);
expect(getUser(user.id)).toBeNull();
});Test đầu có ba assertion nhưng tất cả đều xác minh một thứ: format của display name. Nếu bất kỳ assertion nào fail, bạn biết chính xác cái gì hỏng. Test thứ hai là ba test riêng biệt nhồi vào nhau. Nếu assertion thứ hai fail, bạn không biết là creation hay updating bị hỏng, và assertion thứ ba không bao giờ chạy.
Mô Tả Test Như Tài Liệu#
Các test suite tốt đóng vai trò như tài liệu sống. Người không quen thuộc với code nên có thể đọc mô tả test và hiểu hành vi của tính năng:
describe("PasswordValidator", () => {
describe("minimum length", () => {
it("rejects passwords shorter than 8 characters", () => { /* ... */ });
it("accepts passwords with exactly 8 characters", () => { /* ... */ });
});
describe("character requirements", () => {
it("requires at least one uppercase letter", () => { /* ... */ });
it("requires at least one number", () => { /* ... */ });
it("requires at least one special character", () => { /* ... */ });
});
describe("common password check", () => {
it("rejects passwords in the common passwords list", () => { /* ... */ });
it("performs case-insensitive comparison", () => { /* ... */ });
});
});Khi test suite này chạy, output đọc giống như một specification. Đó là mục tiêu.
Mocking#
Mocking là công cụ mạnh nhất và cũng nguy hiểm nhất trong bộ công cụ testing của bạn. Sử dụng tốt, nó cô lập đơn vị đang test và làm test nhanh và xác định. Sử dụng kém, nó tạo ra những test pass bất kể code làm gì.
vi.fn() — Tạo Mock Function#
Mock đơn giản nhất là một function ghi lại các lần gọi:
const mockCallback = vi.fn();
// Gọi nó
mockCallback("hello", 42);
mockCallback("world");
// Assert các lần gọi
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");Bạn có thể cho nó một giá trị trả về:
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: "Test" }),
});Hoặc làm nó trả về giá trị khác nhau qua các lần gọi liên tiếp:
const mockRandom = vi.fn()
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.9);vi.spyOn() — Quan Sát Method Thật#
Khi bạn muốn quan sát một method mà không thay thế hành vi của nó:
const consoleSpy = vi.spyOn(console, "warn");
validateInput("");
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();spyOn giữ implementation gốc theo mặc định. Bạn có thể ghi đè nó bằng .mockImplementation() khi cần nhưng khôi phục bản gốc sau đó bằng .mockRestore().
vi.mock() — Mock Cấp Module#
Đây là thứ quan trọng nhất. vi.mock() thay thế toàn bộ module:
// Mock toàn bộ module
vi.mock("@/lib/api", () => ({
fetchUsers: vi.fn().mockResolvedValue([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]),
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
// Import giờ sử dụng phiên bản mock
import { fetchUsers } from "@/lib/api";
describe("UserList", () => {
it("displays users from API", async () => {
const users = await fetchUsers();
expect(users).toHaveLength(2);
});
});Vitest tự động hoist các lời gọi vi.mock() lên đầu file. Điều này nghĩa là mock đã sẵn sàng trước khi bất kỳ import nào chạy. Bạn không cần lo lắng về thứ tự import.
Auto Mocking#
Nếu bạn chỉ muốn mọi export được thay bằng vi.fn():
vi.mock("@/lib/analytics");
import { trackEvent, trackPageView } from "@/lib/analytics";
it("tracks form submission", () => {
submitForm();
expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});Không có factory function, Vitest tự động mock tất cả export. Mỗi function được export trở thành một vi.fn() trả về undefined. Điều này hữu ích cho các module bạn muốn tắt tiếng (như analytics hoặc logging) mà không cần chỉ định mọi function.
Clear vs Reset vs Restore#
Điều này khiến ai cũng bị nhầm lẫn ở một thời điểm nào đó:
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
// mockClear — reset lịch sử gọi, giữ implementation
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // vẫn trả về 42
// mockReset — reset lịch sử gọi VÀ implementation
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // không còn trả về 42
// mockRestore — cho spy, khôi phục implementation gốc
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() giờ hoạt động bình thường trở lạiTrong thực tế, dùng vi.clearAllMocks() trong beforeEach để reset lịch sử gọi giữa các test. Dùng vi.restoreAllMocks() nếu bạn đang dùng spyOn và muốn khôi phục bản gốc:
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});Nguy Hiểm Của Việc Mock Quá Nhiều#
Đây là lời khuyên mocking quan trọng nhất tôi có thể đưa ra: mỗi mock là một lời nói dối bạn đang kể cho test của mình. Khi bạn mock một dependency, bạn đang nói "Tôi tin rằng thứ này hoạt động đúng, nên tôi sẽ thay nó bằng phiên bản đơn giản hóa." Nếu giả định của bạn sai, test pass nhưng tính năng thì hỏng.
// Mock quá nhiều — không test được gì hữu ích
vi.mock("@/lib/validator");
vi.mock("@/lib/formatter");
vi.mock("@/lib/api");
vi.mock("@/lib/cache");
it("processes user input", () => {
processInput("hello");
expect(validator.validate).toHaveBeenCalledWith("hello");
expect(formatter.format).toHaveBeenCalledWith("hello");
});Test này xác minh rằng processInput gọi validate và format. Nhưng nếu processInput gọi chúng sai thứ tự thì sao? Nếu nó bỏ qua giá trị trả về? Nếu validation được cho là ngăn bước format chạy? Test không biết. Bạn đã mock đi tất cả hành vi thú vị.
Quy tắc chung: mock ở ranh giới, không phải ở giữa. Mock network request, truy cập file system, và dịch vụ bên thứ ba. Đừng mock các utility function của chính bạn trừ khi có lý do thuyết phục (như chúng tốn kém để chạy hoặc có side effect).
Testing React Component#
Cơ Bản Với Testing Library#
Testing Library thực thi một triết lý: test component theo cách người dùng tương tác với chúng. Không kiểm tra state nội bộ, không inspect component instance, không shallow rendering. Bạn render một component và tương tác với nó thông qua DOM, giống như người dùng thực.
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/Button";
describe("Button", () => {
it("renders with label text", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it("applies variant classes", () => {
render(<Button variant="primary">Submit</Button>);
const button = screen.getByRole("button");
expect(button).toHaveClass("bg-primary");
});
it("is disabled when disabled prop is true", () => {
render(<Button disabled>Submit</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
});Query: getBy vs queryBy vs findBy#
Đây là chỗ người mới hay bị nhầm lẫn. Có ba biến thể query và mỗi cái có use case cụ thể:
getBy* — Trả về element hoặc throw nếu không tìm thấy. Dùng khi bạn mong element tồn tại:
// Throw nếu không tìm thấy button — test fail với error hữu ích
const button = screen.getByRole("button", { name: "Submit" });queryBy* — Trả về element hoặc null nếu không tìm thấy. Dùng khi bạn đang assert thứ gì đó KHÔNG có mặt:
// Trả về null — không throw
expect(screen.queryByText("Error message")).not.toBeInTheDocument();findBy* — Trả về Promise. Dùng cho element xuất hiện bất đồng bộ:
// Đợi tối đa 1000ms cho element xuất hiện
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();Query Ưu Tiên Accessibility#
Testing Library cung cấp các query theo thứ tự ưu tiên có chủ đích:
getByRole— Query tốt nhất. Dùng ARIA role. Nếu component không tìm được bằng role, nó có thể có vấn đề accessibility.getByLabelText— Cho form element. Nếu input không có label, sửa điều đó trước.getByPlaceholderText— Chấp nhận được nhưng yếu hơn. Placeholder biến mất khi người dùng gõ.getByText— Cho element không tương tác. Tìm bằng nội dung text hiển thị.getByTestId— Phương án cuối. Dùng khi không có query ngữ nghĩa nào hoạt động.
// Ưu tiên cái này
screen.getByRole("textbox", { name: "Email address" });
// Hơn cái này
screen.getByPlaceholderText("Enter your email");
// Và chắc chắn hơn cái này
screen.getByTestId("email-input");Thứ hạng không phải tùy ý. Nó khớp với cách công nghệ hỗ trợ điều hướng trang. Nếu bạn có thể tìm element bằng role và tên accessible, screen reader cũng có thể. Nếu bạn chỉ tìm được bằng test ID, có thể bạn có lỗ hổng accessibility.
User Event#
Đừng dùng fireEvent. Dùng @testing-library/user-event. Sự khác biệt rất quan trọng:
import userEvent from "@testing-library/user-event";
describe("SearchInput", () => {
it("filters results as user types", async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} />);
const input = screen.getByRole("searchbox");
await user.type(input, "vitest");
// user.type phát keydown, keypress, input, keyup cho MỖI ký tự
// fireEvent.change chỉ đặt value — bỏ qua event flow thực tế
expect(onSearch).toHaveBeenLastCalledWith("vitest");
});
it("clears input on escape key", async () => {
const user = userEvent.setup();
render(<SearchInput onSearch={vi.fn()} />);
const input = screen.getByRole("searchbox");
await user.type(input, "hello");
await user.keyboard("{Escape}");
expect(input).toHaveValue("");
});
});userEvent mô phỏng toàn bộ chuỗi event mà trình duyệt thật sẽ phát. fireEvent.change là một event tổng hợp đơn lẻ. Nếu component của bạn lắng nghe onKeyDown hoặc dùng onInput thay vì onChange, fireEvent.change sẽ không kích hoạt các handler đó nhưng userEvent.type sẽ làm.
Luôn gọi userEvent.setup() ở đầu và dùng instance user được trả về. Điều này đảm bảo thứ tự event đúng và theo dõi state chính xác.
Testing Tương Tác Component#
Một component test thực tế trông như thế này:
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "@/components/TodoList";
describe("TodoList", () => {
it("adds a new todo item", async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByRole("textbox", { name: /new todo/i });
const addButton = screen.getByRole("button", { name: /add/i });
await user.type(input, "Write tests");
await user.click(addButton);
expect(screen.getByText("Write tests")).toBeInTheDocument();
expect(input).toHaveValue("");
});
it("marks a todo as completed", async () => {
const user = userEvent.setup();
render(<TodoList initialItems={[{ id: "1", text: "Buy groceries", done: false }]} />);
const checkbox = screen.getByRole("checkbox", { name: /buy groceries/i });
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
it("removes completed items when clear button is clicked", async () => {
const user = userEvent.setup();
render(
<TodoList
initialItems={[
{ id: "1", text: "Done task", done: true },
{ id: "2", text: "Pending task", done: false },
]}
/>
);
await user.click(screen.getByRole("button", { name: /clear completed/i }));
expect(screen.queryByText("Done task")).not.toBeInTheDocument();
expect(screen.getByText("Pending task")).toBeInTheDocument();
});
});Lưu ý: không inspect state nội bộ, không component.setState(), không kiểm tra chi tiết implementation. Chúng ta render, tương tác, và assert trên những gì người dùng sẽ thấy. Nếu component tái cấu trúc quản lý state nội bộ từ useState sang useReducer, các test này vẫn pass. Đó là mục đích.
Testing Code Bất Đồng Bộ#
waitFor#
Khi component cập nhật bất đồng bộ, waitFor polling cho đến khi assertion pass:
import { render, screen, waitFor } from "@testing-library/react";
it("loads and displays user profile", async () => {
render(<UserProfile userId="123" />);
// Ban đầu hiển thị loading
expect(screen.getByText("Loading...")).toBeInTheDocument();
// Đợi nội dung xuất hiện
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
// Indicator loading nên biến mất
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});waitFor thử lại callback mỗi 50ms (mặc định) cho đến khi pass hoặc timeout (mặc định 1000ms). Bạn có thể tùy chỉnh cả hai:
await waitFor(
() => expect(screen.getByText("Done")).toBeInTheDocument(),
{ timeout: 3000, interval: 100 }
);Fake Timer#
Khi testing code dùng setTimeout, setInterval, hoặc Date, fake timer cho phép bạn kiểm soát thời gian:
describe("Debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("delays execution by the specified time", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
it("resets timer on subsequent calls", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
vi.advanceTimersByTime(200);
debounced(); // reset timer
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
});Quan trọng: luôn gọi vi.useRealTimers() trong afterEach. Fake timer bị rò rỉ giữa các test gây ra những lỗi khó hiểu nhất mà bạn sẽ bao giờ debug.
Testing Với Fake Timer Và Async Rendering#
Kết hợp fake timer với testing React component đòi hỏi sự cẩn thận. Lập lịch nội bộ của React dùng real timer, nên bạn thường cần tiến timer VÀ flush React update cùng nhau:
import { render, screen, act } from "@testing-library/react";
describe("Notification", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("auto-dismisses after 5 seconds", async () => {
render(<Notification message="Saved!" autoDismiss={5000} />);
expect(screen.getByText("Saved!")).toBeInTheDocument();
// Tiến timer bên trong act() để flush React update
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
});
});API Mocking Với MSW#
Để testing data fetching, Mock Service Worker (MSW) chặn network request ở tầng network. Điều này nghĩa là code fetch/axios của component bạn chạy chính xác như trong production — MSW chỉ thay thế network response:
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
const server = setupServer(
http.get("/api/users", () => {
return HttpResponse.json([
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
]);
}),
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
name: "Alice",
email: "alice@example.com",
});
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("UserList", () => {
it("displays users from API", async () => {
render(<UserList />);
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(await screen.findByText("Bob")).toBeInTheDocument();
});
it("shows error state when API fails", async () => {
// Ghi đè handler mặc định cho test này
server.use(
http.get("/api/users", () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserList />);
expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
});
});MSW tốt hơn việc mock fetch hoặc axios trực tiếp vì:
- Code data fetching thật của component bạn chạy — bạn test tích hợp thật.
- Bạn có thể test xử lý lỗi bằng cách ghi đè handler cho từng test.
- Cùng handler hoạt động trong cả test và browser dev mode (Storybook chẳng hạn).
Integration Test#
Unit Test vs Integration Test#
Unit test cô lập một function hoặc component đơn lẻ và mock mọi thứ khác. Integration test cho phép nhiều đơn vị hoạt động cùng nhau và chỉ mock ranh giới bên ngoài (network, file system, database).
Sự thật là: hầu hết bug tôi thấy trong production xảy ra ở ranh giới giữa các đơn vị, không phải bên trong chúng. Một function hoạt động hoàn hảo khi cô lập nhưng fail vì caller truyền data ở format hơi khác. Một component render tốt với mock data nhưng hỏng khi API response thật có thêm một cấp lồng nhau.
Integration test bắt được những bug này. Chúng chậm hơn unit test và khó debug hơn khi fail, nhưng chúng cho nhiều sự tự tin hơn trên mỗi test.
Testing Nhiều Component Cùng Nhau#
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
// Chỉ mock tầng API — mọi thứ khác là thật
vi.mock("@/lib/api", () => ({
checkout: vi.fn().mockResolvedValue({ orderId: "ORD-123" }),
}));
describe("Shopping Cart Flow", () => {
const renderCart = (initialItems = []) => {
return render(
<CartProvider initialItems={initialItems}>
<ShoppingCart />
</CartProvider>
);
};
it("displays item count and total", () => {
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
{ id: "2", name: "Mouse", price: 49.99, quantity: 2 },
]);
expect(screen.getByText("3 items")).toBeInTheDocument();
expect(screen.getByText("$179.97")).toBeInTheDocument();
});
it("updates quantity and recalculates total", async () => {
const user = userEvent.setup();
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
const incrementButton = screen.getByRole("button", { name: /increase quantity/i });
await user.click(incrementButton);
expect(screen.getByText("$159.98")).toBeInTheDocument();
});
it("completes checkout flow", async () => {
const user = userEvent.setup();
const { checkout } = await import("@/lib/api");
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
await user.click(screen.getByRole("button", { name: /checkout/i }));
expect(checkout).toHaveBeenCalledWith({
items: [{ id: "1", quantity: 1 }],
});
expect(await screen.findByText(/order confirmed/i)).toBeInTheDocument();
expect(screen.getByText("ORD-123")).toBeInTheDocument();
});
});Trong test này, ShoppingCart và CartProvider và các component nội bộ của chúng (hàng item, bộ chọn số lượng, hiển thị tổng) tất cả hoạt động cùng nhau với code thật. Mock duy nhất là API call, vì chúng ta không muốn thực hiện network request thật trong test.
Khi Nào Dùng Integration vs Unit Test#
Dùng unit test khi:
- Logic phức tạp và có nhiều edge case (trình phân tích ngày tháng, state machine, phép tính).
- Bạn cần phản hồi nhanh về hành vi của một function cụ thể.
- Đơn vị tương đối cô lập và không phụ thuộc nhiều vào đơn vị khác.
Dùng integration test khi:
- Nhiều component cần hoạt động cùng nhau đúng cách (form với validation và submission).
- Data chảy qua nhiều tầng (context -> component -> child component).
- Bạn đang test một workflow người dùng, không phải giá trị trả về của function.
Trong thực tế, một test suite lành mạnh nặng về integration test cho tính năng và có unit test cho các tiện ích phức tạp. Các component bản thân được test thông qua integration test — bạn không cần unit test riêng cho mỗi component nhỏ nếu integration test đã thực thi nó.
Coverage#
Chạy Coverage#
vitest run --coverageBạn sẽ cần một coverage provider. Vitest hỗ trợ hai:
# V8 — nhanh hơn, dùng coverage tích hợp của V8
npm install -D @vitest/coverage-v8
# Istanbul — trưởng thành hơn, nhiều tùy chọn cấu hình hơn
npm install -D @vitest/coverage-istanbulCấu hình nó trong config Vitest:
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/*.spec.{ts,tsx}",
"src/test/**",
"src/**/*.d.ts",
"src/**/types.ts",
],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
});Istanbul vs V8#
V8 coverage dùng công cụ đo lường tích hợp của V8 engine. Nó nhanh hơn vì không có bước biến đổi code. Nhưng nó có thể kém chính xác cho một số edge case, đặc biệt xung quanh ranh giới ES module.
Istanbul thêm bộ đếm vào mã nguồn trước khi chạy test. Nó chậm hơn nhưng đã được thử nghiệm nhiều hơn và cho branch coverage chính xác hơn. Nếu bạn đang thực thi coverage threshold trong CI, độ chính xác của Istanbul có thể quan trọng.
Tôi dùng V8 cho phát triển local (phản hồi nhanh hơn) và Istanbul trong CI (thực thi chính xác hơn). Bạn có thể cấu hình provider khác nhau cho từng môi trường nếu cần.
Coverage Thực Sự Nghĩa Là Gì#
Coverage cho bạn biết dòng code nào đã được thực thi trong test. Chỉ thế thôi. Nó không cho bạn biết những dòng đó có được test đúng hay không. Hãy xem xét:
function divide(a: number, b: number): number {
return a / b;
}
it("divides numbers", () => {
divide(10, 2);
// Không có assertion!
});Test này cho 100% coverage của function divide. Nó cũng không test được gì cả. Test sẽ pass nếu divide trả về null, throw error, hoặc phóng tên lửa.
Coverage là chỉ báo phủ định hữu ích: coverage thấp nghĩa là chắc chắn có đường dẫn chưa test. Nhưng coverage cao không nghĩa là code được test tốt. Nó chỉ nghĩa là mọi dòng đã chạy trong một test nào đó.
Line vs Branch#
Line coverage là metric phổ biến nhất nhưng branch coverage có giá trị hơn:
function getDiscount(user: User): number {
if (user.isPremium) {
return user.yearsActive > 5 ? 0.2 : 0.1;
}
return 0;
}Test với getDiscount({ isPremium: true, yearsActive: 10 }) chạm mọi dòng (100% line coverage) nhưng chỉ test hai trong ba nhánh. Đường dẫn isPremium: false và yearsActive <= 5 chưa được test.
Branch coverage bắt được điều này. Nó theo dõi mọi đường dẫn có thể qua logic điều kiện. Nếu bạn định thực thi coverage threshold, dùng branch coverage.
Bỏ Qua Code Được Sinh#
Một số code không nên tính trong coverage. File sinh tự động, định nghĩa type, cấu hình — những thứ này làm phồng metric mà không thêm giá trị:
// vitest.config.ts
coverage: {
exclude: [
"src/**/*.d.ts",
"src/**/types.ts",
"src/**/*.stories.tsx",
"src/generated/**",
".velite/**",
],
}Bạn cũng có thể bỏ qua dòng hoặc khối cụ thể trong mã nguồn:
/* v8 ignore start */
if (process.env.NODE_ENV === "development") {
console.log("Debug info:", data);
}
/* v8 ignore stop */
// Hoặc cho Istanbul
/* istanbul ignore next */
function devOnlyHelper() { /* ... */ }Dùng điều này hạn chế. Nếu bạn thấy mình đang bỏ qua những khối code lớn, hoặc những khối đó cần test hoặc chúng không nên nằm trong báo cáo coverage từ đầu.
Testing Next.js#
Mock next/navigation#
Các component Next.js dùng useRouter, usePathname, hoặc useSearchParams cần mock:
import { vi } from "vitest";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => "/en/blog",
useSearchParams: () => new URLSearchParams("?page=1"),
useParams: () => ({ locale: "en" }),
}));Cho test cần xác minh navigation đã được gọi:
import { useRouter } from "next/navigation";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
describe("LogoutButton", () => {
it("redirects to home after logout", async () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({
push: mockPush,
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
forward: vi.fn(),
});
const user = userEvent.setup();
render(<LogoutButton />);
await user.click(screen.getByRole("button", { name: /log out/i }));
expect(mockPush).toHaveBeenCalledWith("/");
});
});Mock next-intl#
Cho component quốc tế hóa dùng next-intl:
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));Đây là cách tiếp cận đơn giản nhất — bản dịch trả về chính key, nên t("hero.title") trả về "hero.title". Trong assertion, bạn kiểm tra translation key thay vì chuỗi đã dịch thực tế. Điều này làm test không phụ thuộc ngôn ngữ.
Nếu bạn cần bản dịch thực tế trong một test cụ thể:
vi.mock("next-intl", () => ({
useTranslations: () => {
const translations: Record<string, string> = {
"hero.title": "Welcome to My Site",
"hero.subtitle": "Building things for the web",
};
return (key: string) => translations[key] ?? key;
},
}));Testing Route Handler#
Next.js Route Handler là các function thông thường nhận Request và trả về Response. Chúng rất trực quan để test:
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
describe("GET /api/users", () => {
it("returns users list", async () => {
const request = new NextRequest("http://localhost:3000/api/users");
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: expect.any(Number), name: expect.any(String) }),
])
);
});
it("supports pagination via search params", async () => {
const request = new NextRequest("http://localhost:3000/api/users?page=2&limit=10");
const response = await GET(request);
const data = await response.json();
expect(data.page).toBe(2);
expect(data.items).toHaveLength(10);
});
});
describe("POST /api/users", () => {
it("creates a new user", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "Alice", email: "alice@test.com" }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.name).toBe("Alice");
});
it("returns 400 for invalid body", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "" }),
});
const response = await POST(request);
expect(response.status).toBe(400);
});
});Testing Middleware#
Middleware Next.js chạy ở edge và xử lý mọi request. Test nó như một function:
import { middleware } from "@/middleware";
import { NextRequest } from "next/server";
function createRequest(path: string, headers: Record<string, string> = {}): NextRequest {
const url = new URL(path, "http://localhost:3000");
return new NextRequest(url, { headers });
}
describe("middleware", () => {
it("redirects unauthenticated users from protected routes", async () => {
const request = createRequest("/dashboard");
const response = await middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/login");
});
it("allows authenticated users through", async () => {
const request = createRequest("/dashboard", {
cookie: "session=valid-token",
});
const response = await middleware(request);
expect(response.status).toBe(200);
});
it("adds security headers", async () => {
const request = createRequest("/");
const response = await middleware(request);
expect(response.headers.get("x-frame-options")).toBe("DENY");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("handles locale detection", async () => {
const request = createRequest("/", {
"accept-language": "tr-TR,tr;q=0.9,en;q=0.8",
});
const response = await middleware(request);
expect(response.headers.get("location")).toContain("/tr");
});
});Testing Server Action#
Server Action là các async function chạy trên server. Vì chúng chỉ là function, bạn có thể test trực tiếp — nhưng bạn có thể cần mock các dependency chỉ dành cho server:
vi.mock("@/lib/db", () => ({
db: {
user: {
update: vi.fn().mockResolvedValue({ id: "1", name: "Updated" }),
findUnique: vi.fn().mockResolvedValue({ id: "1", name: "Original" }),
},
},
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}));
import { updateProfile } from "@/app/actions/profile";
import { revalidatePath } from "next/cache";
describe("updateProfile", () => {
it("updates user and revalidates profile page", async () => {
const formData = new FormData();
formData.set("name", "New Name");
formData.set("bio", "New bio text");
const result = await updateProfile(formData);
expect(result.success).toBe(true);
expect(revalidatePath).toHaveBeenCalledWith("/profile");
});
it("returns error for invalid data", async () => {
const formData = new FormData();
// Thiếu trường bắt buộc
const result = await updateProfile(formData);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});Mẫu Thực Tế#
Custom Render Function#
Hầu hết dự án cần cùng provider bọc quanh mọi component. Tạo một custom render:
// src/test/utils.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { ReactElement } from "react";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { CartProvider } from "@/contexts/CartContext";
interface CustomRenderOptions extends RenderOptions {
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}
function AllProviders({ children, theme = "light", initialCartItems = [] }: {
children: React.ReactNode;
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}) {
return (
<ThemeProvider defaultTheme={theme}>
<CartProvider initialItems={initialCartItems}>
{children}
</CartProvider>
</ThemeProvider>
);
}
export function renderWithProviders(
ui: ReactElement,
options: CustomRenderOptions = {}
) {
const { theme, initialCartItems, ...renderOptions } = options;
return render(ui, {
wrapper: ({ children }) => (
<AllProviders theme={theme} initialCartItems={initialCartItems}>
{children}
</AllProviders>
),
...renderOptions,
});
}
// Re-export mọi thứ từ testing library
export * from "@testing-library/react";
export { renderWithProviders as render };Giờ mọi file test import từ custom utils thay vì @testing-library/react:
import { render, screen } from "@/test/utils";
it("renders in dark mode", () => {
render(<Header />, { theme: "dark" });
// Header và tất cả children có quyền truy cập ThemeProvider và CartProvider
});Testing Custom Hook#
Vitest hoạt động với renderHook của @testing-library/react:
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
describe("useCounter", () => {
it("starts at initial value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it("increments", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it("decrements with floor", () => {
const { result } = renderHook(() => useCounter(0, { min: 0 }));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(0); // không xuống dưới min
});
});Testing Error Boundary#
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
const ThrowingComponent = () => {
throw new Error("Test explosion");
};
describe("ErrorBoundary", () => {
// Tắt console.error cho error dự kiến
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("displays fallback UI when child throws", () => {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ThrowingComponent />
</ErrorBoundary>
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
it("renders children when no error", () => {
render(
<ErrorBoundary fallback={<div>Error</div>}>
<div>All good</div>
</ErrorBoundary>
);
expect(screen.getByText("All good")).toBeInTheDocument();
expect(screen.queryByText("Error")).not.toBeInTheDocument();
});
});Snapshot Testing (Cẩn Thận)#
Snapshot test có danh tiếng xấu vì mọi người dùng chúng thay thế cho assertion thực sự. Một snapshot toàn bộ output HTML của component là gánh nặng bảo trì — nó vỡ mỗi lần CSS class thay đổi và không ai xem xét diff cẩn thận.
Nhưng snapshot có mục tiêu có thể hữu ích:
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
// Tốt — snapshot nhỏ, có mục tiêu của output pure function
it("formats various currency values consistently", () => {
expect(formatCurrency(0)).toMatchInlineSnapshot('"$0.00"');
expect(formatCurrency(1234.5)).toMatchInlineSnapshot('"$1,234.50"');
expect(formatCurrency(-99.99)).toMatchInlineSnapshot('"-$99.99"');
expect(formatCurrency(1000000)).toMatchInlineSnapshot('"$1,000,000.00"');
});
// Xấu — snapshot khổng lồ không ai xem xét
it("renders the dashboard", () => {
const { container } = render(<Dashboard />);
expect(container).toMatchSnapshot(); // Đừng làm thế
});Inline snapshot (toMatchInlineSnapshot) tốt hơn file snapshot vì giá trị mong đợi hiển thị ngay trong test. Bạn có thể nhìn thoáng qua function trả về gì mà không cần mở file .snap riêng.
Triết Lý Testing#
Test Hành Vi, Không Phải Implementation#
Nguyên tắc này quan trọng đến mức xứng đáng có phần riêng. Hãy xem hai test cho cùng tính năng:
// Test implementation — giòn, vỡ khi refactor
it("calls setState with new count", () => {
const setStateSpy = vi.spyOn(React, "useState");
render(<Counter />);
fireEvent.click(screen.getByText("+"));
expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
// Test hành vi — bền, test những gì người dùng thấy
it("increments the displayed count when plus button is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "+" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});Test đầu vỡ nếu bạn chuyển từ useState sang useReducer, dù component hoạt động chính xác như cũ. Test thứ hai chỉ vỡ nếu hành vi của component thực sự thay đổi. Nó không quan tâm count được quản lý nội bộ thế nào — chỉ rằng bấm "+" làm số tăng lên.
Phép thử litmus đơn giản: bạn có thể refactor implementation mà không thay đổi test không? Nếu có, bạn đang test hành vi. Nếu không, bạn đang test implementation.
Testing Trophy#
Kent C. Dodds đề xuất "Testing Trophy" như một thay thế cho testing pyramid truyền thống:
╭─────────╮
│ E2E │ Ít — tốn kém, chậm, độ tin cậy cao
├─────────┤
│ │
│ Integr. │ Nhiều nhất — tỷ lệ tin cậy/chi phí tốt
│ │
├─────────┤
│ Unit │ Một số — nhanh, tập trung, chi phí thấp
├─────────┤
│ Static │ Luôn luôn — TypeScript, ESLint
╰─────────╯
Pyramid truyền thống đặt unit test ở dưới cùng (nhiều) và integration test ở giữa (ít hơn). Trophy đảo ngược: integration test là điểm ngọt. Lý do:
- Phân tích tĩnh (TypeScript, ESLint) bắt lỗi chính tả, type sai, và lỗi logic đơn giản miễn phí. Bạn thậm chí không cần chạy gì.
- Unit test tuyệt vời cho logic thuần phức tạp nhưng không cho bạn biết các mảnh có hoạt động cùng nhau không.
- Integration test xác minh rằng component, hook, và context hoạt động cùng nhau. Chúng cho nhiều sự tự tin nhất trên mỗi test viết.
- End-to-end test xác minh toàn bộ hệ thống nhưng chậm, không ổn định, và tốn kém để bảo trì. Bạn cần vài cái cho đường dẫn quan trọng nhưng không cần hàng trăm.
Tôi tuân theo phân bổ này trong thực tế: TypeScript bắt hầu hết lỗi type, tôi viết unit test cho tiện ích và thuật toán phức tạp, integration test cho tính năng và user flow, và một vài E2E test cho đường dẫn quan trọng (đăng ký, mua hàng, workflow cốt lõi).
Cái Gì Cho Tự Tin vs Cái Gì Lãng Phí Thời Gian#
Test tồn tại để cho bạn tự tin ship code. Không phải tự tin rằng mọi dòng code đều chạy — tự tin rằng ứng dụng hoạt động cho người dùng. Đây là hai thứ khác nhau.
Tự tin cao, giá trị cao:
- Integration test của checkout flow — bao gồm form validation, API call, cập nhật state, và UI thành công/lỗi.
- Unit test của function tính giá với edge case — floating point, làm tròn, giảm giá, giá trị zero/âm.
- Test rằng route được bảo vệ redirect người dùng chưa xác thực.
Tự tin thấp, lãng phí thời gian:
- Snapshot test của trang marketing tĩnh — vỡ mỗi lần copy thay đổi, không bắt được gì có ý nghĩa.
- Unit test rằng component truyền prop cho child — đang test React, không phải code của bạn.
- Test rằng
useStateđược gọi — đang test framework, không phải hành vi. - 100% coverage của file cấu hình — nó là data tĩnh, TypeScript đã validate shape.
Câu hỏi cần đặt trước khi viết test: "Nếu test này không tồn tại, bug nào có thể lọt vào production?" Nếu câu trả lời là "không có gì TypeScript không bắt được" hoặc "không có gì ai sẽ nhận thấy," test đó có lẽ không đáng viết.
Testing Như Phản Hồi Thiết Kế#
Code khó test thường là code thiết kế kém. Nếu bạn cần mock năm thứ để test một function, function đó có quá nhiều dependency. Nếu bạn không thể render component mà không thiết lập context provider phức tạp, component đó quá gắn kết với môi trường.
Test là người dùng code của bạn. Nếu test gặp khó khăn khi dùng API của bạn, các lập trình viên khác cũng sẽ vậy. Khi bạn thấy mình đang chiến đấu với thiết lập test, hãy coi đó là tín hiệu để refactor code đang test, không phải thêm mock.
// Khó test — function làm quá nhiều
async function processOrder(orderId: string) {
const order = await db.orders.findById(orderId);
const user = await db.users.findById(order.userId);
const inventory = await checkInventory(order.items);
if (!inventory.available) {
await sendEmail(user.email, "out-of-stock", { items: inventory.unavailable });
return { success: false, reason: "out-of-stock" };
}
const payment = await chargeCard(user.paymentMethod, order.total);
if (!payment.success) {
await sendEmail(user.email, "payment-failed", { error: payment.error });
return { success: false, reason: "payment-failed" };
}
await db.orders.update(orderId, { status: "confirmed" });
await sendEmail(user.email, "order-confirmed", { orderId });
return { success: true };
}
// Dễ test hơn — tách biệt mối quan tâm
function determineOrderAction(
inventory: InventoryResult,
payment: PaymentResult
): OrderAction {
if (!inventory.available) return { type: "out-of-stock", items: inventory.unavailable };
if (!payment.success) return { type: "payment-failed", error: payment.error };
return { type: "confirmed" };
}Phiên bản thứ hai là pure function. Bạn có thể test mọi tổ hợp kết quả inventory và payment mà không cần mock database, nhà cung cấp thanh toán, hay dịch vụ email. Logic điều phối (fetch data, gửi email) sống trong một tầng mỏng được test ở cấp integration.
Đây là giá trị thực sự của testing: không phải bắt bug sau khi chúng được viết, mà ngăn chặn thiết kế tồi trước khi chúng được commit. Kỷ luật viết test đẩy bạn hướng tới function nhỏ hơn, interface rõ ràng hơn, và kiến trúc module hóa hơn. Các test là side effect. Cải thiện thiết kế mới là sự kiện chính.
Viết Test Bạn Tin Tưởng#
Điều tồi tệ nhất có thể xảy ra với test suite không phải là nó có khoảng trống. Mà là mọi người ngừng tin tưởng nó. Một test suite với vài flaky test fail ngẫu nhiên trên CI dạy team bỏ qua build đỏ. Khi điều đó xảy ra, test suite còn tệ hơn vô dụng — nó chủ động cung cấp cảm giác an toàn giả.
Nếu test fail không ổn định, sửa nó hoặc xóa nó. Nếu test chậm, tăng tốc nó hoặc chuyển vào suite test chậm riêng. Nếu test vỡ vì thay đổi không liên quan, viết lại nó để test hành vi thay vì implementation.
Mục tiêu là test suite mà mỗi failure nghĩa là thứ gì đó thực sự hỏng. Khi developer tin tưởng test, họ chạy chúng trước mỗi commit. Khi họ không tin, họ bypass bằng --no-verify và deploy với ngón tay bắt chéo.
Xây dựng test suite mà bạn dám đặt cược cuối tuần vào. Không gì kém hơn đáng bảo trì.