← Clean Code: viết code dễ đọc

Bài 9 · Vận dụng · 22 phút

Unit test sạch

Biên soạn bởi Nguyễn Anh Tuấn

Test cũng phải sạch: quy tắc F.I.R.S.T; một khái niệm mỗi test; mẫu AAA (Arrange-Act-Assert); tên test kể chuyện; test đỡ lưng cho refactor; ba luật TDD.

Nhiều dự án có test - nhưng test khó đọc, giòn, tên mơ hồ, fail ngẫu nhiên. Kết quả: người ta ngừng tin vào test, ngừng chạy CI khi vội, rồi ngừng cập nhật test khi sửa code. Test tích tụ nợ kỹ thuật y hệt code sản phẩm - và khi test không còn là lưới an toàn, mọi refactor đều trở thành canh bạc.

❌ Test bẩn - nhiều khái niệm lộn xộn, fail = không biết vấn đề ở đâu

// test nay kiem tra nhieu thu cung luc - kho doc, kho debug
it('test order stuff', () => {
  const items = [{ price: 100000, qty: 2 }, { price: 50000, qty: 1 }];
  const total = calculateTotal(items);
  expect(total).toBe(250000);
  const discounted = applyDiscount(total, 10);
  expect(discounted).toBe(225000);
  expect(() => applyDiscount(total, -5)).toThrow();
  expect(() => applyDiscount(total, 110)).toThrow();
  const empty = calculateTotal([]);
  expect(empty).toBe(0);
  // Test fail: "Expected 0 to be 0" - o dong nao? Vi sao?
});
  • Test bẩn khó đọc → người ta ngừng cập nhật khi code thay đổi → test mất tác dụng.
  • Test cũng là code: phải giữ sạch, đặt tên, cấu trúc rõ như code sản phẩm.
  • Test fail mà không biết vấn đề ở đâu là dấu hiệu test làm nhiều việc cùng lúc.

Năm chữ cái tóm tắt phẩm chất của unit test tốt:

  • Fast - Nhanh: unit test chạy trong mili-giây; cả bộ test chạy trong vài giây. Chậm = người ta ngừng chạy thường xuyên.
  • Independent - Độc lập: mỗi test tự setup và tự dọn; không phụ thuộc thứ tự chạy hay trạng thái chia sẻ với test khác.
  • Repeatable - Lặp lại được: chạy 100 lần ra cùng kết quả; không phụ thuộc thời gian, mạng, dữ liệu ngoài, hay may rủi.
  • Self-validating - Tự kiểm: pass hoặc fail rõ ràng - không cần mắt người đọc log để quyết định; `expect` làm điều đó.
  • Timely - Kịp thời: viết test gần với lúc viết code - không phải sau 3 tháng khi code đã cứng và khó test hơn.

Flaky test - tệ hơn không có test

Test fail ngẫu nhiên (flaky) phá hủy niềm tin vào CI. Sau vài lần CI đỏ vì test "ngẫu nhiên" mà không phải do code lỗi, người ta bắt đầu bấm "re-run" mà không điều tra. Repeatable là quy tắc quan trọng nhất trong F.I.R.S.T.

AAA (Arrange-Act-Assert) là cách chia mỗi test thành ba phần rõ ràng: chuẩn bị dữ liệu → thực hiện → kiểm tra kết quả. Kết hợp với tên test mô tả kịch bản cụ thể, người đọc hiểu test đang kiểm tra gì mà không cần đọc thân test.

✅ Test sạch theo AAA - mỗi test một khái niệm, tên kể chuyện (Vitest)

import { describe, it, expect } from 'vitest';

function calculateTotal(items: { price: number; qty: number }[]): number {
  return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}

function applyDiscount(total: number, discountPct: number): number {
  if (discountPct < 0 || discountPct > 100)
    throw new Error(`Phan tram giam gia khong hop le: ${discountPct}`);
  return total * (1 - discountPct / 100);
}

describe('calculateTotal', () => {
  it('tinh tong nhieu san pham co so luong khac nhau', () => {
    // Arrange
    const items = [{ price: 100000, qty: 2 }, { price: 50000, qty: 1 }];
    // Act
    const total = calculateTotal(items);
    // Assert
    expect(total).toBe(250000);
  });

  it('tra ve 0 khi danh sach rong', () => {
    // Arrange
    const items: { price: number; qty: number }[] = [];
    // Act
    const total = calculateTotal(items);
    // Assert
    expect(total).toBe(0);
  });
});

describe('applyDiscount', () => {
  it('ap dung dung phan tram giam gia vao tong tien', () => {
    // Arrange
    const total = 250000;
    const discountPct = 10;
    // Act
    const result = applyDiscount(total, discountPct);
    // Assert
    expect(result).toBe(225000);
  });

  it('bao loi khi phan tram giam gia am', () => {
    expect(() => applyDiscount(250000, -5))
      .toThrow('Phan tram giam gia khong hop le: -5');
  });

  it('bao loi khi phan tram giam gia vuot qua 100', () => {
    expect(() => applyDiscount(250000, 110))
      .toThrow('Phan tram giam gia khong hop le: 110');
  });
});
  • Arrange: chuẩn bị dữ liệu và điều kiện - độc lập với test khác.
  • Act: gọi đúng một hàm/method - phần này thường chỉ một dòng.
  • Assert: kiểm tra kết quả - nếu fail, biết ngay vấn đề ở đâu vì test chỉ làm một việc.

Tên test: mô tả kịch bản, không mô tả implementation

Tên tốt: bao loi khi phan tram giam gia am - đọc xong biết điều kiện và kết quả. Tên xấu: test applyDiscount error case - không nói kịch bản nào. Cách đặt tên theo dạng "[điều kiện/đầu vào] - [kết quả mong đợi]" thường rõ nhất.

TDD (Test-Driven Development) là kỹ thuật viết test TRƯỚC code. Ba luật đơn giản tạo ra vòng lặp ngắn giúp code luôn có test và luôn sạch:

  • Luật 1 - Chỉ viết test fail: không viết code sản phẩm cho đến khi có một unit test đang fail.
  • Luật 2 - Chỉ viết đủ để fail: test fail vì lý do cụ thể, không fail vì lỗi compile.
  • Luật 3 - Chỉ viết đủ để test xanh: viết đúng lượng code sản phẩm để test đó xanh - không thêm.

TDD mini: đỏ → xanh → refactor với Vitest

// Buoc 1 (Do): Viet test truoc - test nay fail vi ham chua ton tai
import { describe, it, expect } from 'vitest';
// import { formatVND } from './money'; // chua co - se fail compile

// Buoc 2 (Xanh): viet ham vua du de test xanh
function formatVND(amount: number): string {
  return amount.toLocaleString('vi-VN') + ' ₫';
}

describe('formatVND', () => {
  it('format so nguyen thanh tien Viet Nam', () => {
    expect(formatVND(1500000)).toBe('1.500.000 ₫');
  });

  it('format so 0', () => {
    expect(formatVND(0)).toBe('0 ₫');
  });

  it('format so lon', () => {
    expect(formatVND(10000000)).toBe('10.000.000 ₫');
  });
});

// Buoc 3 (Refactor): giu test xanh, lam code dep hon neu can
// (ham nay da ngan - khong can refactor; vong lap TDD ket thuc)

Lợi ích của TDD không chỉ là có test - mà là vòng lặp đỏ-xanh-refactor ép bạn thiết kế interface trước khi implement, giữ mỗi bước thay đổi nhỏ, và refactor liên tục có lưới an toàn.

Bài Ranh giới vừa học nhắc: adapter dễ test vì nhận dependency qua tham số. Điều đó phản ánh một nguyên tắc lớn hơn: code dễ test thường là code có thiết kế tốt - ít coupling, trách nhiệm rõ ràng. Và khi bạn có bộ test sạch, refactor trở thành hoạt động an toàn, có thể lặp lại:

  • Không có test: refactor là canh bạc - bạn không biết mình vừa gãy gì cho đến khi user báo.
  • Có test bẩn: test fail ngẫu nhiên → người ta bỏ qua → không khác gì không có test.
  • Có test sạch: sửa code, chạy test, xanh = tự tin; đỏ = biết ngay gãy ở đâu và phần gì.

Sẽ học thêm ở bài tiếp theo: Code smell & refactoring

Bài tiếp theo - Code smell & refactoring - đi thẳng vào việc nhận diện mã mùi (code smell) và refactor từng bước nhỏ an toàn. Mọi bước refactor ở đó đều giả định có bộ test sạch đỡ lưng - chính xác những gì bài này xây dựng.

Câu hỏi thường gặp

Không bắt buộc - TDD là kỹ thuật, không phải quy định. Lợi ích chính của TDD: test trước ép bạn suy nghĩ về interface và hành vi trước khi nghĩ về implementation; vòng đỏ-xanh-refactor giữ nhịp làm việc đều đặn. Nhiều lập trình viên dùng TDD cho code mới và viết test sau cho code đang refactor. Điều thực sự quan trọng không phải là thứ tự viết - mà là test phải tồn tại và phải sạch.

Không có con số tuyệt đối, nhưng nguyên tắc "một khái niệm mỗi test" thường dẫn đến ít expect. Nếu bạn thấy mình đang viết 5+ expect kiểm tra những thứ khác nhau trong một it() - đó là dấu hiệu nên tách. Nhiều expect kiểm tra cùng một thứ (vd kiểm tra nhiều field của cùng một object trả về) thì ổn. Câu hỏi tốt hơn: "Nếu test này fail, tôi biết ngay vấn đề là ở đâu không?"

Test phải cho kết quả nhất quán mỗi khi chạy - không phụ thuộc vào: (1) thứ tự chạy test (mỗi test tự dọn dẹp trạng thái của mình), (2) thời gian hiện tại (mock Date.now() nếu cần), (3) ngẫu nhiên (dùng seed cố định), (4) dữ liệu ngoài (mock API, DB). Test fail ngẫu nhiên (flaky test) tệ như không có test - người ta ngừng tin vào kết quả CI sau vài lần false alarm.

Cả hai đều có chỗ: tạo inline (trong Arrange) cho thấy rõ dữ liệu liên quan đến test đó - không cần nhảy đi xem fixture. Fixture (beforeEach, factory function) hợp lý khi dữ liệu setup phức tạp và giống nhau nhiều test. Kinh nghiệm thực tế: nếu đọc test xong phải nhảy đi xem fixture để hiểu test này kiểm tra gì - fixture đó là phần nên inline.

Có - các nguyên tắc tên rõ, AAA, một khái niệm mỗi test áp dụng cho mọi loại test. Điểm khác của integration test: "Repeatable" khó hơn (DB thật, network thật), nên cần setup/teardown cẩn thận hơn, hoặc dùng container/transaction rollback. F.I.R.S.T vẫn là kim chỉ nam - chỉ là integration test thường đánh đổi Fast để đổi lấy coverage thực tế hơn.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Trong F.I.R.S.T, "Independent" yêu cầu điều gì cụ thể?

  1. 1

    Tìm và phân tích test bẩn

    Mở một file test hiện có (dự án của mèo con hoặc mã nguồn mở). Tìm một test bẩn - nhiều assert không liên quan, tên mơ hồ, không có cấu trúc Arrange-Act-Assert. Ghi lại: vi phạm quy tắc nào trong F.I.R.S.T?

    Hoàn thành khi: Liệt kê ít nhất 2 vấn đề cụ thể (vd tên không kể kịch bản, nhiều khái niệm trong một test) và chỉ ra quy tắc F.I.R.S.T bị vi phạm.

  2. 2

    Tách test bẩn theo AAA

    Từ test bẩn vừa tìm, viết lại thành 2-3 test nhỏ theo mẫu AAA. Đặt tên theo dạng "mô tả kịch bản + điều kiện cụ thể".

    Hoàn thành khi: Mỗi test nhỏ có 3 phần Arrange/Act/Assert rõ ràng (có thể dùng comment // Arrange v.v.); tên test đọc xong biết ngay kịch bản; mỗi test fail độc lập.

  3. 3

    Đặt tên test kể chuyện

    Tìm 3 test trong codebase đang có tên chung chung ("test1", "should work", "test valid input"). Đặt lại tên theo dạng mô tả kịch bản + trạng thái + kết quả mong đợi.

    Hoàn thành khi: 3 tên mới đọc xong biết ngay: hàm nào, điều kiện gì, kết quả gì - không cần đọc thân test.

  4. 4

    Kiểm tra F.I.R.S.T

    Chọn một test trong codebase. Đánh giá theo F.I.R.S.T: Fast (chạy nhanh không?), Independent (phụ thuộc thứ tự hay trạng thái chia sẻ không?), Repeatable (luôn cùng kết quả không?), Self-validating (tự pass/fail không cần mắt người?), Timely (viết kịp thời không?).

    Hoàn thành khi: Điểm đánh giá 5 chữ cái với lý do ngắn; nếu có chữ nào fail, đề xuất cách sửa cụ thể.

  5. 5

    TDD mini: viết test trước

    Chọn một hàm nhỏ chưa viết (vd formatVND(amount: number): string trả "1.500.000 ₫"). Viết test trước (expect cụ thể), xem test đỏ, viết hàm vừa đủ để test xanh, rồi refactor nếu cần.

    Hoàn thành khi: Ít nhất 3 test case (bao gồm biên); hàm implement sau khi đã có test đỏ; test xanh sau khi implement.

  6. 6

    Test là lưới an toàn cho refactor

    Chọn một hàm có test. Refactor nó (đổi tên biến nội bộ, tách sub-function, đổi thuật toán) mà KHÔNG đổi hành vi. Chạy test sau mỗi bước nhỏ.

    Hoàn thành khi: Mô tả: refactor làm gì; test có fail ở bước nào không; nếu có, bạn phát hiện và sửa lỗi gì; kết quả cuối test xanh.