Tại sao phải tìm hiểu?

TDD là gì thì mọi lập trình viên (đặc biệt là Rails và các ngôn ngữ mang phong cách Agile development) có kinh nghiệm một chút chắc chắn đã biết tới và đang thực hành. Lý do ư? Vì đó là điều bắt buộc của bất cứ thông tin tuyển dụng nào trong ngành phần mềm (đôi khi không ghi ra vì đã ngầm hiểu là mặc định).

Thế nhưng để thực hành TDD tốt và đúng thì không chắc tất cả các lập trình viên có kinh nghiệm đều làm được! Trong bài viết này, tôi muốn chia sẻ quan điểm cá nhân của bản thân về TDD và cách để làm việc hiểu quả với triết lý này.

Khái niệm tổng quan

TDD là gì? TDD là viết tắt của Test Driven Development, tức triết lý phát triển sản phẩm dựa trên quan điểm kiểm thử liên tục. Nói một cách nôm na thì quá trình phát triển sản phẩm sẽ luôn gắn liền với việc kiểm thử (test). Một quy trình kinh điển gồm các bước:

  1. Viết test mô tả chức năng đang hiện thực một cách khái quát và tương đối đầy đủ nhất có thể -> test failed
  2. Viết code hiện thực đến khi các test ra kết quả đúng song song với việc cập nhật test cụ thể, đầy đủ hơn -> test passed
  3. Tái cấu trúc, tối ưu code (refactor code) nhưng vẫn đảm bảo test báo đúng -> test passed

Đặc điểm của từng bước:

  • Bước 1: để áp dụng triết lý TDD tốt thì bước 1 là rất quan trọng, quyết định đầu ra (output), chất lượng của sản phẩm
  • Bước 2: Đây là phần công việc cơ bản của ngành lập trình. Tuy nhiên, do đang đề cập đến TDD nên chúng ta không đi sâu phần phân tích tính năng và hiện thực, thay vào đó chúng ta đi sâu vào phong cách, quy trình code
  • Bước 3: có thể lặp đi lặp lại nhiều lần cho tới khi người lập trình cảm thấy hài lòng (định lượng bởi hiệu suất của code, độ “sạch sẽ” của code, tính bảo mật, …). Trong bước 3 này test có thể bị failed nhiều lần do quá trình refactor gây ra lỗi

Mục đích của TDD

  • Giúp giảm tỉ lệ lỗi của phần mềm
  • Giúp lập trình viên tự tin hơn, làm việc hiệu quả hơn trong suốt quá trình lập trình

Vấn đề

Thế nhưng, viết test chi tiết tới mức nào là đủ?

Viết test bao quát (cover) bao nhiêu là đủ?

Đây là những câu hỏi khó để trả lời một cách chính xác với bất cứ cấp độ lập trình viên nào!

Một số quy tắc phổ biến

  1. Đơn giản hoá: hãy giữ cho đoạn test ban đầu đơn giản nhất có thể rồi sau đó phát triển dần dần nó lên. Điều này giúp chúng ta giảm thời gian tìm nguyên nhân gây lỗi (debug).
  2. Hiện thực hoá: hãy đối xử với test của bạn như với sản phẩm của bạn khi được chạy trong môi trường thực tế. Điều này có nghĩa là hãy thiết lập những hoàn cảnh thực tế, xấu, ngoài mong đợi chứ đừng chỉ test những trường hợp tối ưu.
  3. Tập trung: từng đoạn test cần ngắn gọn, rõ ràng nhất có thể để nó cũng chính là tài liệu (documentation) của phần mềm. Hãy làm điều này bằng cách viết test thông minh, ngắn gọn và tối giản phần khởi tạo (setup) không liên quan đến logic cần test.

Một số lỗi phổ biến và cách khắc phục

  • Test bằng cách hiện thực lại code: đây là lỗi rất phổ biến mà nhiều người rất dễ mắc phải. Để giải quyết vấn đề này chúng ta cần dẹp bỏ khái niệm test là “dự đoán 1 sẽ bằng 1”:
new_function.rb
  def sum(a,b)
    a + b
  end

test.rb
  a = random
  b = random
  exepected_sum = a + b
  assert_equal(sum(a,b), exepected_sum)

Chúng ta cần tránh xa cách tiếp cận này. Khi test và code cùng ngôn ngữ, viết giống hệt nhau thì dĩ nhiên ra kết quả giống nhau với cùng tham số, việc gì phải test?! Sau đây là cách test đúng đắn:

new_function.rb
  def sum(a,b)
    a + b
  end

test.rb
  a = 1
  b = 2
  exepected_sum = 3
  assert_equal(sum(a,b), exepected_sum)

Dĩ nhiên, với phép cộng đơn thuần thì chúng ta đang làm công việc test toán tử cơ bản của ngôn ngữ lập trình rồi :D. Tôi chỉ muốn ví dụ ra rằng đối với các logic phức tạp hơn, chúng ta cần tính tay và test xem phần test tự động chạy có đúng với điều ta mong muốn.

  • Test thiếu trường hợp: một cách khắc phục rất dễ và cần thiết là chúng ta hãy đơn giản hoá code, đừng nhồi nhét quá nhiều logic và (&&), hoặc (||), giao (&), hợp (|) … đầy tính toán tử vào cùng một dòng điều kiện. Thay vào đó, khái niệm hoá chúng thành các hàm nhỏ hơn hoặc chia thành các luồng nhỏ hơn. Ví dụ:
def something
  if (self.total <= 0) && (price == 0)
    0
  elsif (((self.total - price) >= 0) || (price == 0)
    1
  else
    0
  end
end

nên chuyển thành

def something(price)
  if payable(price)
    1
  else
    0
  end
end

def payable(price)
  self.total >= price
end

Bây giờ, 1 hay 0 là điều chúng ta cần test bằng cách stub hàm payable (hàm payable được test riêng).

  • Test chồng chéo: hãy nhìn vào ví dụ phía trên, nếu trong khi test hàm “something” mà chúng ta đi tạo lại từng trường hợp cụ thể cho hàm payable thì đó là sự chồng chéo mà chúng ta cần tránh.

Trong trường hợp này, chúng ta chỉ cần test 2 trường hợp là khi hàm payable trả về truefalse. Chúng ta có thể tạo dữ liệu phù hợp cho 2 trường hợp hoặc tốt nhất là stub ép nó trả về true/false. Stub giúp chúng ta thực hiện quy tắc Tập trung nêu trên.

Kết luận

Cho tới khi nào con người còn tự lập trình sản phẩm phần mềm bằng bộ não và bàn tay của mình, khi đó việc testing vẫn là vô cùng quan trọng :v. Đơn giản vì con người thì luôn có khả năng mắc sai lầm, chúng ta chỉ làm đúng khi có đủ chế tài kiểm soát bản thân (có thể là luật pháp xã hội, cũng có thể là toà án lương tâm ^^).

Và như vậy, khái niệm TDD sẽ đồng hành cùng testing trong khoảng thời gian khá dài nữa cho tới khi một triết lý khác ưu việt hơn thay thế.

Hy vọng thông tin của bài viết giúp bạn có thêm một góc nhìn về TDD và giúp công việc lập trình của bạn thuận lợi, hiệu quả hơn.