Kiểm thử đơn vị trong JavaScript: Các mẫu thực tế giúp ngăn chặn lỗi sản phẩm

Table of Contents
Hầu hết các nhà phát triển đều trải qua một giai đoạn khó chịu với việc kiểm thử đơn vị tự động. Bạn dành hàng giờ để viết các bộ kiểm thử toàn diện, đạt hơn 90% độ bao phủ mã trên bảng điều khiển của mình và cảm thấy tự tin khi triển khai lên môi trường sản xuất. Tuy nhiên, chỉ trong vài giờ, người dùng gặp phải các sự cố thời gian chạy trong các tình huống mà bộ kiểm thử của bạn không bao giờ phát hiện ra. Tệ hơn nữa, mỗi khi bạn tái cấu trúc các phương thức trợ giúp nội bộ của một hàm, hàng tá kiểm thử thất bại mặc dù hành vi ứng dụng bên ngoài hoàn toàn không thay đổi.
Vấn đề hiếm khi nằm ở trình chạy kiểm thử—cho dù bạn sử dụng Vitest, Jest hay trình chạy kiểm thử gốc của Node. Sự cố xảy ra vì các nhà phát triển được dạy về độ bao phủ cấu trúc thay vì xác minh hành vi.
Trong hướng dẫn này, chúng tôi sẽ phân tích các mẫu thực tế, nguyên tắc mocking và các kỹ thuật dựa trên thuộc tính giúp biến các bộ kiểm thử dễ vỡ thành các mạng lưới an toàn linh hoạt.
1. Mẫu AAA: Thực thi các kịch bản đơn nhiệm
Một anti-pattern phổ biến là kết hợp nhiều xác nhận và các thay đổi nhiều bước trong một trường hợp kiểm thử nguyên khối duy nhất:
// ❌ ANTI-PATTERN: Multi-scenario test with vague intent
test('user checkout flow works', async () => {
const cart = new Cart();
cart.add({ id: 'item_1', price: 50 });
expect(cart.total).toBe(50);
cart.applyDiscount('SUMMER10');
expect(cart.total).toBe(45);
cart.remove('item_1');
expect(cart.total).toBe(0);
});
Khi kiểm thử này thất bại ở dòng 46, bạn phải đọc toàn bộ dấu vết thực thi để hiểu liệu lỗi đó do tính toán giỏ hàng, logic mã giảm giá hay việc xóa mặt hàng gây ra.
Giải pháp: Arrange-Act-Assert với thiết lập rõ ràng
Chia các chuyển đổi trạng thái riêng biệt thành các kiểm thử nguyên tử, cô lập theo Arrange-Act-Assert (AAA):
// ✅ PATTERN: Focused, isolated behavioral assertions
describe('Cart Discount Engine', () => {
it('applies percentage discount to non-empty cart', () => {
// Arrange
const cart = createCartWithItems([{ price: 100, quantity: 1 }]);
const coupon = { code: 'SAVE20', discountPercent: 20 };
// Act
cart.applyCoupon(coupon);
// Assert
expect(cart.total).toBe(80);
});
it('rejects expired coupon codes without mutating total', () => {
// Arrange
const cart = createCartWithItems([{ price: 100, quantity: 1 }]);
const expiredCoupon = { code: 'EXPIRED', discountPercent: 50, expiresAt: new Date(2020, 1, 1) };
// Act & Assert
expect(() => cart.applyCoupon(expiredCoupon)).toThrowError(/expired/i);
expect(cart.total).toBe(100);
});
});
Lưu ý việc sử dụng createCartWithItems—một Test Object Factory. Các factory giữ cho bước Arrange của bạn ngắn gọn và bảo vệ các kiểm thử của bạn khỏi những thay đổi đối với chữ ký hàm tạo Cart.
2. Mock các ranh giới, không bao giờ mock các chi tiết nội bộ
Một trong những cách nhanh nhất để tạo ra các kiểm thử dễ vỡ là mock các phương thức riêng tư nội bộ hoặc các chi tiết triển khai:
// ❌ BRITTLE: Mocking internal implementation details
jest.spyOn(paymentService, '_calculateTaxRate').mockReturnValue(0.08);
jest.spyOn(paymentService, '_validateZipCode').mockReturnValue(true);
Khi bạn tái cấu trúc paymentService để tính thuế thông qua một bảng tra cứu thuế thống nhất, mã của bạn hoạt động hoàn hảo, nhưng tất cả các kiểm thử đơn vị của bạn ngay lập tức thất bại vì phương thức riêng tư _calculateTaxRate không còn tồn tại.
Quy tắc vàng: Mock tại các ranh giới kiến trúc
Chỉ mock:
- Network I/O: Các API của bên thứ ba (Stripe, GitHub, OpenSearch) sử dụng các công cụ như MSW (Mock Service Worker).
- System Clocks: Bộ hẹn giờ và múi giờ.
- Hardware / Non-Deterministic APIs: Tạo số ngẫu nhiên, truy cập hệ thống tệp.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { processSubscriptionPayment } from './payment';
// Mock the network transport boundary
vi.mock('@/lib/stripe-client', () => ({
stripe: {
charges: {
create: vi.fn(),
},
},
}));
import { stripe } from '@/lib/stripe-client';
describe('processSubscriptionPayment', () => {
it('charges customer card and marks subscription active', async () => {
vi.mocked(stripe.charges.create).mockResolvedValueOnce({
id: 'ch_123',
status: 'succeeded',
});
const result = await processSubscriptionPayment({
customerId: 'cus_999',
amountInCents: 2000,
});
expect(result.status).toBe('active');
expect(stripe.charges.create).toHaveBeenCalledWith({
customer: 'cus_999',
amount: 2000,
currency: 'usd',
});
});
});
3. Kiểm thử dựa trên thuộc tính với fast-check
Các kiểm thử đơn vị truyền thống xác minh các kịch bản mà kỹ sư đã chủ động nghĩ đến: input = "john@example.com", input = "", input = null. Các lỗi gây ra sự cố trong môi trường sản xuất gần như luôn phát sinh từ các đầu vào mà không ai lường trước: các ký tự Unicode, số dấu phẩy động âm, chuỗi ô nhiễm prototype hoặc các payload lớn.
Kiểm thử dựa trên thuộc tính xác thực các bất biến toán học trên hàng trăm đầu vào ngẫu nhiên bằng cách sử dụng các thư viện như fast-check:
import { test } from 'vitest';
import fc from 'fast-check';
import { encodeBase64Url, decodeBase64Url } from './crypto-utils';
test('round-trip encoding invariant: decode(encode(x)) === x for all UTF-8 strings', () => {
fc.assert(
fc.property(fc.fullUnicodeString(), (rawText) => {
const encoded = encodeBase64Url(rawText);
const decoded = decodeBase64Url(encoded);
return decoded === rawText;
}),
{ numRuns: 500 } // Runs 500 distinct randomized variations
);
});
Nếu fast-check tìm thấy một lỗi (ví dụ: xử lý byte null \u0000 hoặc chuỗi biểu tượng cảm xúc), nó sẽ thực hiện thu hẹp—đơn giản hóa dần dần đầu vào bị lỗi xuống trường hợp kiểm thử có thể tái tạo tối thiểu.
4. Định thời không đồng bộ & Tính xác định của đồng hồ
Kiểm thử mã liên quan đến thời gian chờ, debouncing hoặc thử lại là một nguồn thường xuyên gây ra các bộ kiểm thử không ổn định khi các kỹ sư sử dụng các độ trễ setTimeout tùy ý trong các phần kiểm thử.
Luôn sử dụng bộ hẹn giờ giả định xác định:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { debounce } from './debounce';
describe('debounce utility', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('delays execution until inactivity window elapses', () => {
const callback = vi.fn();
const debouncedFn = debounce(callback, 300);
debouncedFn();
debouncedFn();
debouncedFn();
// Fast-forward time by 200ms - should not execute yet
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
// Advance past the 300ms threshold
vi.advanceTimersByTime(150);
expect(callback).toHaveBeenCalledTimes(1);
});
});
5. DAMP hơn DRY trong các bộ kiểm thử
Trong mã ứng dụng sản xuất, DRY (Don't Repeat Yourself) là một nguyên tắc thiết kế cốt lõi. Trong các bộ kiểm thử, việc tuân thủ nghiêm ngặt DRY làm giảm khả năng bảo trì. Khi logic thiết lập kiểm thử được trừu tượng hóa qua 15 hook beforeEach lồng nhau trong 3 tệp trợ giúp khác nhau, việc hiểu tại sao một kiểm thử thất bại đòi hỏi phải nhảy qua nhiều tệp.
Ưu tiên DAMP (Descriptive And Meaningful Phrases):
- Giữ thiết lập kiểm thử hiển thị bên trong kiểm thử hoặc lệnh gọi factory ngay lập tức.
- Tránh các biến có thể thay đổi được chia sẻ giữa các kiểm thử trong
beforeEach. - Viết tên kiểm thử truyền tải rõ ràng quy tắc nghiệp vụ mong đợi:
// ❌ Obscure test name
test('calculate() error', () => ...)
// ✅ Clear, self-documenting test name
test('throws InsufficientInventoryError when requested quantity exceeds available stock', () => ...)
Các câu hỏi thường gặp
Tôi có nên đặt mục tiêu đạt 100% độ bao phủ kiểm thử không?
Không. Việc theo đuổi 100% độ bao phủ dẫn đến việc kiểm thử mã boilerplate (getters, setters, DTO đơn giản) và khuyến khích các kiểm thử dễ vỡ mock các chi tiết nội bộ. Hãy đặt mục tiêu đạt 75–85% độ bao phủ tập trung mạnh vào các tính toán nghiệp vụ, logic ủy quyền, chuyển đổi trạng thái và các trường hợp biên.
Khi nào tôi nên sử dụng kiểm thử tích hợp thay vì kiểm thử đơn vị?
Sử dụng kiểm thử đơn vị cho logic nghiệp vụ thuần túy, các phép biến đổi thuật toán, các hàm định dạng và các bộ giảm trạng thái. Sử dụng kiểm thử tích hợp (chẳng hạn như kiểm thử các truy vấn cơ sở dữ liệu với một testcontainer thực hoặc các tuyến API với Supertest) bất cứ khi nào xác minh các tương tác qua các ranh giới cơ sở dữ liệu, message broker hoặc các pipeline middleware nhiều bước.
Làm cách nào để ngăn chặn các kiểm thử không ổn định trong CI?
Các kiểm thử không ổn định thường do trạng thái được chia sẻ giữa các kiểm thử, các lời hứa bị từ chối không được xử lý, các phụ thuộc thời gian đồng hồ thực hoặc các lệnh gọi mạng bên ngoài không được kiểm soát. Loại bỏ sự không ổn định bằng cách cô lập các giao dịch cơ sở dữ liệu cho mỗi kiểm thử, thay thế bộ hẹn giờ thực bằng bộ hẹn giờ giả và mock tất cả các yêu cầu HTTP bên ngoài của bên thứ ba bằng MSW.
Bạn cũng có thể thích
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

Giới thiệu về Phát triển Hướng Kiểm thử (TDD)
Tôi đã phản đối TDD trong nhiều năm, cho đến khi một lần refactor thất bại đã thuyết phục tôi; đây là cách chu trình Red-Green-Refactor thực sự diễn ra trong thực tế — và tại sao việc viết test trước lại thay đổi nhiều hơn là chỉ độ bao phủ test của bạn.
Read more
Làm chủ kiểm thử E2E với Playwright vào năm 2026
Làm chủ kiểm thử E2E với Playwright vào năm 2026: tự động chờ, cách ly ngữ cảnh trình duyệt, chặn mạng, lưu trữ xác thực và song song hóa CI.
Read more
TypeScript Generics: Các Mẫu Nâng Cao cho API An Toàn Kiểu
Nắm vững các mẫu generics TypeScript nâng cao: conditional types, mapped types, template literal types, branded types, discriminated unions và xây dựng các thư viện sản xuất an toàn kiểu.
Read more