Skill TDD: 5 Bước Viết Code Chuẩn Red-Green-Refactor

Trong quá trình phát triển các hệ thống phần mềm phức tạp, một trong những thách thức lớn nhất mà chúng ta đối mặt là làm sao để duy trì mã nguồn sạch, ...

Trong quá trình phát triển các hệ thống phần mềm phức tạp, một trong những thách thức lớn nhất mà chúng ta đối mặt là làm sao để duy trì mã nguồn sạch, dễ bảo trì và hạn chế tối đa lỗi logic khi mở rộng tính năng. Thực tế thì việc áp dụng skill tdd (Test-Driven Development) chính là chìa khóa vàng giúp giải quyết bài toán này một cách triệt để. Thế nhưng, nếu bạn hỏi mình về cách đa số lập trình viên đang triển khai TDD hiện nay, mình sẽ không ngần ngại trả lời rằng phần lớn đều đang hiểu sai hoặc áp dụng sai phương pháp. Bài viết này sẽ phân tích chuyên sâu về skill tdd, giúp bạn nắm vững tư duy phát triển hướng kiểm thử (test-first) và quy trình 5 bước chuẩn để tạo ra những sản phẩm chất lượng cao.

Triết lý cốt lõi của Skill TDD: Tập trung vào hành vi thay vì chi tiết triển khai

Nguyên lý cốt lõi nhất của skill tdd cực kỳ đơn giản nhưng lại bị nhiều người bỏ qua: kiểm thử phải xác minh hành vi thông qua các giao diện công khai (public interfaces), chứ không phải chi tiết triển khai bên trong. Mã nguồn bên trong có thể thay đổi hoàn toàn sau các đợt tái cấu trúc, nhưng các bài kiểm thử thì không nên bị ảnh hưởng nếu hành vi của hệ thống vẫn giữ nguyên.

Thú thật là mình đã thấy rất nhiều dự án gặp tình trạng “ác mộng refactor”. Đó là khi bạn chỉ thay đổi tên một hàm nội bộ hoặc gom nhóm lại một số biến, nhưng đột nhiên hàng chục test case lăn ra lỗi. Vấn đề là ở đây: các bài kiểm thử đó đã bị liên kết quá chặt chẽ (tightly coupled) với cấu trúc bên trong của mã nguồn. Một bộ test được thiết kế tốt theo skill tdd phải đóng vai trò như một bản đặc tả tính năng trực quan, mô tả hệ thống làm được gì chứ không quan tâm hệ thống làm việc đó như thế nào.

Để dễ hình dung hơn về triết lý này của skill tdd, hãy cùng theo dõi bảng so sánh chi tiết dưới đây giữa các bài kiểm thử tốt (Good Tests) và các bài kiểm thử tồi (Bad Tests):

Khía cạnhKiểm thử tốt (Integration-Style)Kiểm thử tồi (Coupled to Implementation)
Đối tượng kiểm thửHành vi quan sát được của hệ thống qua public APIChi tiết triển khai nội bộ, cấu trúc private method
Sự phụ thuộc (Mocks)Chỉ mock ở ranh giới hệ thống (API bên thứ ba, DB)Mock tràn lan các lớp nội bộ và collaborators
Khả năng refactorKhông đổi khi refactor cấu trúc code bên trongDễ dàng đổ vỡ dù hành vi nghiệp vụ không đổi
Giá trị tài liệuĐóng vai trò như tài liệu đặc tả (specification)Đọc giống như một bản sao chép lại logic của code
Mức độ tin cậyCao, phản ánh chính xác trải nghiệm của người dùngThấp, có thể pass giả lập do mock sai thực tế

Bên cạnh đó, một lỗi kinh điển khác khi áp dụng skill tdd là viết các bài kiểm thử mang tính chất lặp thừa (tautological tests). Đây là dạng test mà giá trị mong đợi (expected value) được tính toán bằng chính công thức của mã nguồn đang chạy. Ví dụ, bạn viết hàm cộng hai số và assert kết quả bằng phép cộng của chính hai tham số đầu vào trong test. Test kiểu này luôn luôn đúng về mặt cú pháp nhưng vô giá trị về mặt logic, bởi vì nếu code chạy sai thì test cũng sẽ sai theo một cách đồng bộ. Khi thực hành skill tdd, giá trị mong đợi bắt buộc phải đến từ nguồn độc lập như một hằng số đã biết, một ví dụ thực tế hoặc tài liệu đặc tả nghiệp vụ.

Sai lầm kinh điển: Lát cắt ngang (Horizontal Slices) và những hệ lụy nguy hiểm

Một trong những sai lầm phổ biến nhất khiến lập trình viên chán nản và từ bỏ skill tdd là tiếp cận theo kiểu “Lát cắt ngang” (Horizontal Slices). Đây là hiện tượng lập trình viên hiểu sai quy trình Red-Green-Refactor bằng cách viết toàn bộ các bài kiểm thử cho toàn bộ tính năng trước (giai đoạn Red lớn), sau đó mới bắt tay vào viết hàng loạt mã nguồn để pass tất cả các test đó (giai đoạn Green lớn).

Cách làm này mang lại những hệ lụy cực kỳ nguy hiểm cho dự án:

  • Mơ hồ về hành vi thực tế: Viết test hàng loạt buộc bạn phải tưởng tượng ra toàn bộ cấu trúc dữ liệu và chữ ký hàm trước khi code chạy thực tế. Bạn dễ dàng rơi vào trạng thái thiết kế sai lệch so với nhu cầu thực của ứng dụng.
  • Test quá brittle (dễ vỡ): Do chưa viết code thực tế, bạn sẽ tập trung kiểm tra hình dáng dữ liệu (shapes) thay vì hành vi cốt lõi. Khi bắt đầu code và nhận ra cấu trúc dữ liệu cần tối ưu lại, bạn sẽ phải sửa lại toàn bộ đống test đã viết.
  • Mất động lực lập trình: Giai đoạn Red kéo dài quá lâu khiến bạn không thấy được phản hồi tích cực từ hệ thống. Cảm giác chạy hàng chục test bị đỏ liên tục trong nhiều giờ liền rất dễ gây nản lòng.

Thực tế thì skill tdd sinh ra để lập trình viên kiểm soát tốt tiến độ và thiết kế của mình. Lát cắt ngang hoàn toàn đi ngược lại tinh thần này. Nó biến skill tdd từ một công cụ hỗ trợ thiết kế thành một rào cản hành chính khô khan, làm chậm tốc độ phát triển mà không mang lại giá trị tương xứng cho chất lượng phần mềm.

Lát cắt dọc (Vertical Slices): Quy trình 5 bước áp dụng Skill TDD hiệu quả

Để khắc phục triệt để sai lầm từ lát cắt ngang, skill tdd khuyến khích áp dụng tư duy “Lát cắt dọc” (Vertical Slices). Bạn sẽ chia nhỏ tính năng thành các hành vi độc lập có thể kiểm thử được từ đầu đến cuối. Với mỗi hành vi nhỏ này, bạn sẽ áp dụng vòng lặp Red-Green-Refactor một cách nhanh chóng. Dưới đây là quy trình 5 bước chuẩn mực để áp dụng skill tdd theo lát cắt dọc vào công việc lập trình hàng ngày:

Bước 1: Lên kế hoạch và thiết kế giao diện kiểm thử

Trước khi gõ bất kỳ dòng code nào, bạn cần xác định rõ hành vi nhỏ tiếp theo mà bạn muốn hệ thống thực hiện là gì khi triển khai skill tdd. Hãy thiết kế API công khai cho tính năng đó: Tên hàm là gì? Nhận vào tham số nào? Trả về kết quả ra sao? Bước này buộc bạn phải đứng dưới góc nhìn của người sử dụng code (client code) để thiết kế giao diện sạch sẽ và dễ dùng nhất.

Bước 2: Viết duy nhất một bài kiểm thử bị thất bại (Red Phase)

Tiến hành viết chính xác một bài kiểm thử mô tả hành vi bạn vừa lên kế hoạch ở Bước 1 của quy trình skill tdd. Hãy nhớ là chỉ viết duy nhất một test case cho một hành vi cụ thể. Chạy trình chạy test (test runner) và đảm bảo rằng bài test này bị thất bại (đỏ) với lý do rõ ràng (ví dụ: hàm chưa tồn tại hoặc kết quả trả về không khớp). Điều này xác nhận rằng test của bạn đang thực sự kiểm tra một hành vi mới chưa được hiện thực.

Bước 3: Viết mã nguồn tối thiểu để bài kiểm thử vượt qua (Green Phase)

Bây giờ, hãy viết mã nguồn triển khai cho tính năng. Điểm mấu chốt ở đây là chỉ viết lượng code vừa đủ trong vòng lặp skill tdd, thậm chí là tối thiểu hoặc viết code “bẩn” (hardcode giá trị trả về), miễn là làm cho bài test chuyển sang màu xanh (Green). Đừng cố gắng tối ưu hóa cấu trúc code hay lo lắng về các trường hợp biên khác ở bước này. Mục tiêu duy nhất là đưa hệ thống về trạng thái an toàn nhanh nhất có thể.

Bước 4: Tái cấu trúc mã nguồn một cách an toàn (Refactor Phase)

Khi bài test đã chuyển sang màu xanh, bạn đã có một lưới an toàn cực kỳ vững chắc từ việc áp dụng skill tdd. Đây là lúc bạn tiến hành dọn dẹp mã nguồn: loại bỏ các phần trùng lặp, tối ưu thuật toán, gom nhóm các biến, áp dụng các nguyên lý thiết kế như SOLID. Hãy chạy lại bộ test liên tục trong quá trình refactor để đảm bảo rằng bạn không làm hỏng hành vi cũ. Nếu test bị đỏ, bạn lập tức biết mình vừa sai ở đâu và có thể khôi phục lại dễ dàng.

Bước 5: Lặp lại quy trình cho lát cắt dọc tiếp theo

Sau khi hoàn thành đợt refactor và mã nguồn đã sạch sẽ, hãy quay lại Bước 1 để chọn hành vi nhỏ tiếp theo của hệ thống và tiếp tục vòng lặp skill tdd. Bằng cách lặp đi lặp lại quy trình này, bạn sẽ xây dựng mã nguồn lên một cách tự nhiên, từng bước một. Bạn luôn kiểm soát được toàn bộ mã nguồn của mình và không bao giờ phải đối mặt với trạng thái hệ thống không chạy được trong thời gian dài.

Khi nào nên Mock? Cách vẽ ranh giới hệ thống chuẩn xác với skill tdd

Một vấn đề gây nhiều tranh cãi khi áp dụng skill tdd là kỹ thuật giả lập (Mocking). Lập trình viên thường có xu hướng lạm dụng mock để cô lập hoàn toàn lớp đang test với tất cả các lớp khác xung quanh. Điều này dẫn đến những bài test cực kỳ khô khan, khó đọc và hoàn toàn mất khả năng phát hiện lỗi khi các lớp tích hợp với nhau.

Quy tắc vàng của skill tdd là: Chỉ mock ở các ranh giới hệ thống (system boundaries). Ranh giới hệ thống là những phần nằm ngoài tầm kiểm soát trực tiếp của bạn hoặc có chi phí vận hành quá lớn trong môi trường kiểm thử. Cụ thể bao gồm:

  • Các dịch vụ bên thứ ba (Cổng thanh toán Stripe, dịch vụ gửi email SendGrid, AWS S3).
  • Thời gian hệ thống hoặc các yếu tố ngẫu nhiên (chúng ta cần kết quả ổn định để test).
  • Một số thao tác nặng trên hệ thống tệp tin hoặc mạng diện rộng.

Đối với cơ sở dữ liệu khi thực hiện skill tdd, nếu có thể, hãy ưu tiên sử dụng cơ sở dữ liệu kiểm thử thực tế chạy cục bộ (ví dụ: SQLite in-memory hoặc PostgreSQL trong Docker container) thay vì mock toàn bộ các câu lệnh truy vấn SQL. Việc mock cơ sở dữ liệu thường làm mất đi khả năng phát hiện các lỗi cú pháp SQL hoặc ràng buộc dữ liệu thực tế.

Đặc biệt, tuyệt đối không được mock các lớp nội bộ hoặc các cộng sự bên trong hệ thống mà bạn hoàn toàn kiểm soát được. Nếu lớp A gọi lớp B và cả hai đều là code do bạn viết, hãy để chúng tương tác thực tế với nhau trong bài test. Điều này giúp bài test của bạn có tính chất tích hợp cao hơn và phản ánh đúng hoạt động của ứng dụng khi chạy thực tế.

Để hệ thống dễ dàng kiểm thử ở các ranh giới, bạn cần thiết kế mã nguồn theo hướng dễ giả lập. Hai kỹ thuật phổ biến nhất là sử dụng Dependency Injection (tiêm phụ thuộc) và thiết kế giao diện theo kiểu SDK. Thay vì khởi tạo trực tiếp dịch vụ bên ngoài bên trong hàm, hãy truyền nó vào như một tham số thông qua constructor hoặc đối số của hàm. Điều này giúp bạn dễ dàng thay thế nó bằng một phiên bản giả lập (mock object) khi chạy test mà không cần can thiệp vào mã nguồn chạy thực tế.

Thực hành Skill TDD: Ví dụ cụ thể quy trình Red-Green-Refactor với TypeScript

Để hiểu rõ cách áp dụng skill tdd vào thực tế, chúng ta sẽ cùng nhau giải quyết một bài toán kinh điển: Xây dựng tính năng tính toán giỏ hàng và thanh toán (Checkout) cho một ứng dụng thương mại điện tử bằng ngôn ngữ TypeScript và công cụ kiểm thử Vitest.

Giai đoạn 1: Red Phase – Viết bài test đầu tiên mô tả hành vi mong muốn

Chúng ta bắt đầu bằng việc tạo file kiểm thử checkout.test.ts để chuẩn bị thực hành skill tdd. Ở đây, chúng ta thiết kế giao diện của hàm thanh toán: hàm nhận vào một đối tượng giỏ hàng (Cart) và một cổng dịch vụ thanh toán (PaymentService) giả lập ở ranh giới hệ thống. Chúng ta mong muốn khi thanh toán thành công, hàm sẽ trả về trạng thái xác nhận chuẩn skill tdd.

import { describe, test, expect, vi } from "vitest";

// Định nghĩa giao diện cho PaymentService nằm ở ranh giới hệ thống
interface PaymentService {
  charge(amount: number): Promise<boolean>;
}

interface CartItem {
  id: string;
  price: number;
  quantity: number;
}

interface Cart {
  items: CartItem[];
}

describe("Checkout Flow with skill tdd", () => {
  test("should confirm checkout when payment is successful", async () => {
    const cart: Cart = {
      items: [
        { id: "prod-1", price: 100, quantity: 2 },
        { id: "prod-2", price: 50, quantity: 1 }
      ]
    };

    // Tạo mock cho PaymentService ở ranh giới hệ thống
    const mockPaymentService: PaymentService = {
      charge: vi.fn().mockResolvedValue(true)
    };

    // Gọi hàm checkout chưa được triển khai (mong đợi lỗi biên dịch hoặc chạy lỗi)
    const result = await checkout(cart, mockPaymentService);

    expect(result.status).toBe("confirmed");
    expect(result.totalPrice).toBe(250);
  });
});

Khi chạy lệnh kiểm thử, TypeScript sẽ báo lỗi biên dịch vì hàm checkout chưa được định nghĩa. Đây chính là trạng thái Đỏ (Red) đầu tiên của chúng ta. Chúng ta đã hoàn thành xuất sắc bước khởi đầu của skill tdd.

Giai đoạn 2: Green Phase – Triển khai mã nguồn tối thiểu

Bây giờ, chúng ta sẽ viết mã nguồn tối thiểu trong file checkout.ts để bài test vượt qua nhanh nhất có thể. Chúng ta khai báo hàm, tính tổng tiền giỏ hàng và gọi dịch vụ thanh toán.

export async function checkout(cart: any, paymentService: any) {
  let totalPrice = 0;
  for (const item of cart.items) {
    totalPrice += item.price * item.quantity;
  }

  const paymentSuccess = await paymentService.charge(totalPrice);
  
  if (paymentSuccess) {
    return {
      status: "confirmed",
      totalPrice: totalPrice
    };
  }

  return {
    status: "failed",
    totalPrice: totalPrice
  };
}

Chúng ta import hàm checkout vào file test và chạy lại trình kiểm thử. Kết quả trả về màu xanh (Green) rực rỡ! Bài test đã vượt qua thành công nhờ triết lý skill tdd. Đây là một mốc cực kỳ quan trọng trong skill tdd, xác nhận rằng tính năng cơ bản đã hoạt động đúng như mong đợi.

Giai đoạn 3: Refactor Phase – Tối ưu hóa cấu trúc code dưới lưới an toàn

Nhìn vào mã nguồn ở trên khi thực hành skill tdd, chúng ta thấy kiểu dữ liệu vẫn đang dùng any và việc tính tổng tiền giỏ hàng đang được đặt trực tiếp bên trong hàm checkout. Điều này vi phạm nguyên lý Single Responsibility (Đơn nhiệm) trong SOLID. Dưới sự bảo vệ của bài test đã viết, chúng ta tiến hành tái cấu trúc bằng cách thêm kiểu dữ liệu đầy đủ và tách hàm tính tổng tiền giỏ hàng ra riêng.

export interface PaymentService {
  charge(amount: number): Promise<boolean>;
}

export interface CartItem {
  id: string;
  price: number;
  quantity: number;
}

export interface Cart {
  items: CartItem[];
}

export interface CheckoutResult {
  status: "confirmed" | "failed";
  totalPrice: number;
}

// Hàm tính tổng tiền giỏ hàng được tách riêng
export function calculateTotalPrice(cart: Cart): number {
  return cart.items.reduce((total, item) => total + item.price * item.quantity, 0);
}

export async function checkout(
  cart: Cart, 
  paymentService: PaymentService
): Promise<CheckoutResult> {
  const totalPrice = calculateTotalPrice(cart);
  const paymentSuccess = await paymentService.charge(totalPrice);
  
  return {
    status: paymentSuccess ? "confirmed" : "failed",
    totalPrice
  };
}

Chúng ta chạy lại bộ test một lần nữa. Mọi thứ vẫn xanh! Điều này chứng minh rằng việc tái cấu trúc cấu trúc mã nguồn diễn ra hoàn hảo mà không hề làm thay đổi hay phá hỏng hành vi bên ngoài của hệ thống. Bạn thấy đấy, việc áp dụng skill tdd giúp bạn tự tin dọn dẹp và tối ưu code hơn bao giờ hết.

Kết nối với các kỹ năng khác trong hệ sinh thái phát triển phần mềm

Trong thực tế phát triển phần mềm hiện đại, việc sử dụng các công cụ hỗ trợ thông minh là điều vô cùng phổ biến. Khi kết hợp skill tdd với các công cụ như công cụ Find-Skills AI, lập trình viên có thể nhanh chóng tìm kiếm và tích hợp các đoạn mã kiểm thử chuẩn mực cho các thư viện phức tạp. Điều này giúp tiết kiệm đáng kể thời gian thiết lập ban đầu và tập trung tối đa vào việc tư duy nghiệp vụ phần mềm.

Bên cạnh đó, sau khi bạn đã viết xong mã nguồn bằng skill tdd, việc đưa mã nguồn qua một quy trình đánh giá tự động là bước tiếp theo để đảm bảo chất lượng. Bạn có thể sử dụng Review-Code Skill để đánh giá lại toàn diện cấu trúc thiết kế, hiệu năng cũng như tính bảo mật của mã nguồn. Sự kết hợp giữa việc tự bảo vệ bằng test case và đánh giá tĩnh từ AI sẽ tạo ra một quy trình phát triển phần mềm không kẽ hở.

Kết luận: Nâng tầm tư duy lập trình với Skill TDD

Nói một cách đơn giản, skill tdd không chỉ là một phương pháp kiểm thử phần mềm thông thường, mà nó là một triết lý thiết kế hệ thống và rèn luyện tư duy lập trình cực kỳ mạnh mẽ. Bằng cách tập trung vào hành vi thay vì cấu trúc bên trong, áp dụng lát cắt dọc thay vì lát cắt ngang, và chỉ giả lập ở ranh giới hệ thống, bạn sẽ tạo ra những bộ kiểm thử đáng tin cậy và tồn tại lâu bền cùng dự án.

Nếu bạn chưa bao giờ thực hành skill tdd một cách nghiêm túc, lời khuyên chân thành của mình là hãy bắt đầu ngay từ ngày hôm nay với một tính năng nhỏ nhất trong dự án của bạn. Viết một bài test bị đỏ, làm cho nó xanh, và sau đó dọn dẹp nó thật sạch sẽ theo đúng tinh thần của skill tdd. Bạn sẽ sớm cảm nhận được sự tự tin tuyệt đối mỗi khi nhấn nút deploy hệ thống lên môi trường production. Bạn nghĩ sao về phương pháp này? Hãy để lại ý kiến của bạn nhé!