Test Driven Development (TDD) là gì?

1. When to Write Tests? Khi nào thì ta cần viết test?

  • viết test sau quá trình implementation
  • viết test trước quá trình implementation

Hiển hiên, 2 ý kiến trên ngược chiều nhau. Bạn sẽ phải chọn một trong 2 cách với từng đoạn code của mình, và dĩ nhiên cả 2 đều được chấp nhận trong quá trình bạn viết code.

1.1. Test Last

Viết test sau khi code có những mặt ưu và nhược điểm như sau. Ưu điểm chính là test được viết khi những chức năng của object được test đã khá hoàn chỉnh và bạn có sự hiểu biết về nó. Còn nhược điểm chính là nó làm developer tập trung vào test implementation thay vì test interface (behavior). Điều này có thể dẫn tới:

  • test dính chặt với code của bạn (tightly coupling) và nó làm bạn phải viết lại test rất nhiều mỗi khi code thay đổi.
  • khuyến khích developer được chọn viết test nào trước.

Và lại, khi sử dụng cách tiếp cận "test last", luôn luôn có những sự cám dỗ làm cho developer lười viết test. Tại sao lại phải viết test? Khi bạn nghĩ rằng code của mình chạy khá đúng rồi. Tại sao lại phải viết test? Khi bạn phải viết thứ gì đó rồi chạy nó và làm cho kết quả có màu xanh là được. "test last" yêu cầu ở bạn một tính kỷ luật cực cao trong quá trình viết test sau quá trình implementation. Và khi deadline thúc vào ass, sự cám dỗ bị nổi lên, bạn không viết test - bạn tiết kiệm được rất nhiều thời gian, những unit test không cần thiết. Đối với tôi, đây là những lý do cực kỳ nghiêm trọng để bạn từ bỏ cách tiếp cận "test last". Một trường hợp duy nhất được coi là hợp lý - khi làm việc với code người khác để lại.

1.2 Test First

Từ h về sau của phần này sẽ nói về test first, và bây giờ hãy bắt đầu với một miêu tả nho nhỏ về đặc điểm quan trọng nhất của nó. Viết test trước khi code làm developer thực sự nghĩ về behavior của object được test, nó đối lập với implementation. Nó loại bỏ implementation xuống mức tối thiểu trong mỗi test case, không còn những đoạn code logic thừa thãi có trong test case nữa. Nó còn giúp tỉ lệ phủ code của bạn rất cao (gần 100%). Cách tiếp cận này có thể được áp dụng ở mọi level của testing, và rất phổ biến cũng như phù hợp với unit test.

2. TDD

TDD = quá trình 3 màu

  1. Viết 1 test fail (Red)
  2. Sửa code cho test pass (Green)
  3. Loại bỏ code dư thừa - clean code (Blue)

2.1 Red

Nghĩ về một vài chức năng sẽ được implement trong code và gọi nó ở trong test. Những chức năng (method) này chưa được implement, cho nên test sẽ fail. Không sao cả, bây giờ bạn đã biết:

  • chức năng này không hoạt động đúng.
  • một khi nó được implement, nó sẽ được chứng minh là chạy đúng khi test từ red -> green.

Đầu tiên bạn có thể thấy khó chịu khi phải viết test cho những method thậm chí còn chưa được implement. TDD yêu cầu một sự thay đổi nhỏ trong thói quen viết code của bạn, nhưng thỉnh thoảng bạn sẽ thấy cách viết này giúp bạn design code tốt hơn. Bằng việc viết test trước, bạn chuyển từ viết code -> viết API -> tiện lợi cho những người sử dụng. Unit Test của bạn chính là những người dùng đầu tiên của API đó. Đây là những gì TDD thực sự hướng tới: API design.

Khi suy nghĩ với tư cách là người sử dụng đoạn code sắp sửa được viết bởi bạn, bạn nên tập trung vào những thứ gì thật cần thiết. Nên hỏi câu hỏi kiểu như: "có thật sự mình phải viết getter menthod trả về collection này hay sẽ là tiện hơn khi viết method trả về phần tử lớn nhất của collection?". Và bạn cần phải trả lời những câu hỏi như thế này bằng việc viết test. Sẽ không còn những method thực sự không cần thiết và được giữ lại bởi những lý do kiểu như "_biết đâu chúng có thể hữu dụng trong tương la_i", không còn auto-generate getters/setters, một immutable class có thể thích hợp hơn nhiều. Tập trung vào những gì client thực sự cần, và viết test cho chính bọn này, không còn làm gì khác!

Viết test trước, bạn sẽ không thực sự biết implement sẽ như thế nào(thậm chí vài manh mối để implement code). Điều này rất tốt 😃 , điều này có nghĩa test của bạn có nhiều cơ hội trở thành behavior test của một object thay vì đi sâu vào implementation testing. Kết quả của hành động này là việc dễ maintain unit test của bạn, test càng độc lập với implementation càng tốt.

Luôn luôn bắt đầu với một fail test, và luông luông phải nhìn thấy nó red. Không được bỏ qua bước này! Nó là một trong những cách ít ỏi khi học về chất lượng của test.

Dĩ nhiên, bạn không thể chạy test ngay khi bạn viết nó được. Why? Bởi vì nếu bạn thật sự tuân theo "không bao giờ viết code mà không có test fail" quy luật 😃) thì bạn sẽ bị ăn lỗi method/class doesn't exist ngay. Thường để IDE giúp bạn tạo empty method/class trước, sau đó chạy test. Trong thực tế, viết một failing test trước thỉnh thoảng có thể gặp nhiều khó khăn. (Refer to section 4.9)

Làm cách nào để chọn test nào sẽ viết tiếp

Nếu chỉ nói với bạn đơn giản là hãy viết 1 failing test thì thực sự là không hay lắm. Nghe có vẻ rất dễ, nhưng lúc thực sự làm thì như thế nào? Giả sử chúng ta có một list các chức năng cần phải được implement, và list unit test phủ toàn bộ chúng nữa. Chúng ta phải trả lời câu hỏi là chọn cái nào đầu tiên để viết test. Và sau khi bạn kết thúc quá trình implement nó, và tất nhiên cả TDD circle nữa, chọn cái nào tiếp theo đây?

Đây là một vấn đề hay gặp phải, cho tới bây h thì không có hướng đi theo chuẩn nào cả. Không có hướng đi chung có lợi nhất để tìm ra được test cái gì tiếp. Dĩ nhiên, có một vài tip được share lại và nó có thể giúp bạn. Hãy cùng xem xét nó:

The Low-Hanging Fruit. : "hãy bắt đầu cực ký đơn giản, viết những test case siêu dễ hiểu"

Kỹ thuật này đặc biệt hữu hiệu khi chúng ta đang bị kẹt. Viết cái gì đó, thậm chí cái gì đó quá bình thường hoặc không quan trọng, có thể giúp chúng ta vượt qua nó. Khi bạn phân vân nên chọn cái nào để viết test, ... và lúc đấy chỉ viết được một tí thôi, nhưng những test case đơn giản như vậy có thể giúp bạn tìm kiếm những mảnh ghép còn lại. Nó giúp bạn 1 ít còn hơn là chưa viết đc cái gì.

Một ví dụ viết test case đơn giản:

  • Viết một đoạn parameter checking (hãy kệ mục đích của nó, viết cái đã)
  • Hay khi viết 1 cái parser, bắt đầu viết test case truyền empty string vào và trả về null.

The Most Informative One. Một cách tiếp cận khác là viết test với cái nào mà bạn có hiểu nó nhiều nhất, việc này giống như đá quả bóng ở vị trí quen thuộc nhất của bạn sẽ tăng khả năng vào cao nhất.

Dĩ nhiên, nó hay dẫn tới những tình huống phân vân bậc nhất. Well, bạn phải chọn một cái, dù thế nào 😃 vậy, thay vì xét qua xét lại, tại sao không xem xét hành động cụ thể?

Cách tiếp cận này giống như "chẳng là vấn đề nếu như trận đấu đầu tiên của tôi đi đấu với nhà vô địch, bởi vì nếu bạn là nhà vô địch bạn chắc chắn phải loại hắn ta". Một số người coi cầu này như là động lực cho họ.

Câu hỏi bây h là làm thế nào để biết test nào đem lại cho bạn nhiều nhất knowledge về các chức năng phải implement. Câu trả lời rất đơn giản, khả năng cao là test mà bạn vẫn không biết làm thế nào để pass được.

Trong trrường hợp của ví dụ parsing lúc trước, bạn có thể dùng cách tiếp cận này và bắt đầu bằng cách parse 1 câu đầy đủ. Cách này chắc chắn dạy bạn rất nhiều về functionality implemented.

First The Typical Case, Then Corner Cases. Thường chúng ta sẽ bắt đầu với các case thông thường. Nghĩ về những function hay được dùng nhất, khi viết một tokenizer, hãy bắt đầu với 1 valid sentence là input. Cách tiếp cận này đảm bảo bạn đã đạt được thứ gì đó đáng giá ngay từ khi bắt đầu.

Listen To Your Experience. Có thể cách hiệu quả nhất để đương đầu với "next test" là lắng nghe những người có kinh nhiệm.

**Readable Assertion Message **

Sau khi bạn đã có failing test và trước khi bạn bắt đầu implement code để sửa nó thành green thì lời khuyên đưa ra là hãy để ý đến một điều nữa: đảm bảo message in ra bởi failing test nói đúng những gì đang bị sai. Thỉnh thoảng, mà thật ra là hay có trong thực tế, default information được in ra của JUnit không chấp nhận được. Nếu nó không, hãy làm việc với error message đến khi bạn thấy result ổn rồi thì next.

2.2. GREEN - Write the Simplest Thing that Works

Bây giờ bạn đã có failing test, và một message rất clear, bạn cận phải làm cho test pass - bằng cách viết code, dĩ nhiên là vậy 😃

Đến được điểm này nó vẫn chưa thực sự tốt cho lắm. Cụ thể hơn là viết một lượng code ít nhất để pass qua test. Trc tiên, tập trung task mà bạn đang làm việc, bạn phải làm cho nó xanh cái đã, đừng nghĩ qúa nhiều về việc cải tiến code, đừng lo lắng về toàn bộ class, đừng suy nghĩ về việc tính năng của bạn code nhìn nó phải "nice". Đừng cố gắng đáp ứng hết các yêu cầu đó từ khi mà test vẫn còn chưa được viết. Chỉ một lý do, làm test của bạn xanh cái đã, và không gì nữa.

Hãy nhớ, sẽ còn những test khác. Chúng sẽ phủ những requirement khác. Và khi đến lượt chúng, bạn có thể vẫn sẽ thêm những tính năng cool đang được thôi thúc trong đầu bạn "implement ngay!" mặc dù có thể requirement thực sự cần, thực sử không. Nhưng hãy tập trung vào task đang làm.

Ở một mặt khác, đừng sa vào những cái bẫy ngược lại. "Đơn giản" và "đần độn" không phải là 2 từ cùng nghĩa! Tập trung vào task hiện tại không có nghĩa là viết nó một cách ngu học.

2.3. REFACTOR - Improve the Code

Câu hỏi đặt ra là: liệu quá trình refactore liên tục thêm vào những simple feature lần lượt sẽ làm code của bạn lẫn lộn, không clear, nhìn rất chán? Code sẽ chạy được nhưng nó không dễ để được làm sạch và tuân theo OOD.

Mỗi khi test pass, bạn có thể lại thay đổi code, mạng lưới test từ trước giúp bạn tự tin là bạn chẳng sai đc khi vẫn giữ được màu xanh yêu dấu. Cứ tiếp tục và refactor lại, bỏ duplicated code, thay đổi tên method... và quay trở lại test liên tục. Chúng sẽ nói lên chính xác cái gì nguy gây nguy hiểm, nếu có, quá trình refactor của bạn đã gây ra nó.

Và vì sao lại bước này lại có? Như một số người hay nói "nếu nó làm việc được, đừng sửa nó" - tại sao lại gây gánh nặng cho bản thân? Câu trả lời là: trong quá trình trước (viết một lượng code ít nhất để pass test), bạn chỉ tập trung vào một việc duy nhất, và hệ quả là code không đủ clear. Nó chạy được nhưng có thể là ác mộng cho quá trình maintain. Và vượt ra khỏi phạm trù này, khi code mà chỉ pass test, bạn chỉ nghĩ về từng mảnh nhỏ trong code. Bây h là thời gian để suy nghĩ lại bức tranh toàn cảnh và refactor, không chỉ là vài dòng được viết trước đó, có thể nhiều thứ khác...

**Refactoring the Tests **

Bạn có nên refactor cả test nữa không? Có chứ, hãy nhớ nó là nền móng xây dụng lên code của bạn, vì thế nó nên có nền móng vững chãi và đc viết với châts lượng cao nhất. Chất lượng của code phụ thuộc vào sức mạnh của test, cho nên tốt hơn là bạn nên nghĩ là làm như vậy thực sự rất tốt.

Refactor là quá trình tái cấu trúc code mà không thay đổi tính năng. Khi nói về test, điều có có nghĩa refactor test cũng như code, gọi đúng method, sử dụng argument, và sử dụng chung những câu assert.

Một vấn đề rõ ràng là khi sửa test thì không có cái test nào để test cái vừa sửa 😃, bạn có thể gây ra những thay đổi không mong muốn thay vì mục đích của refactor. Một mối nguy hiểm, nhưng cũng không nguy hiểm nếu như bạn nhìn lại. Đầu tiên, unit test, nếu viết đúng thì rất đơn giản. Nó không chứa những logic phức tạp mà có thể làm test của bạn fail khi thay đổi code. Khi bạn refactor test, bạn càng tham gia vào quá trình moving things around - move thứ gì đấy - thay vì là viết test logic.

Adding Javadocs

Trong quá trình refactor, tôi cũng quan tâm tới Java docs - cả 2 điều dành cho production code và test. Có 2 vấn đề chính:

  1. Đầu tiên, design của bạn vẫn chưa được cố định. Đây là vấn đề khi biết bất cứ document nào, khi nào những thứ này có thể bị thay đổi?
  2. Viết document bây h có thể can thiệp vào luồng suy nghĩ testing của bạn. Não của bạn h chỉ tập trung vào viết test. Ngắt luồng làm việc của bạn có thật sự tốt hay k?

Câu trả lời của tôi đưa ra:

  1. Giữ doc của bạn ngắn - nó sẽ làm bạn cảm thấy đỡ đau đớn khi có gì đó thay đổi. Chỉ viết về mục đích nghiệp vụ của class, method và những thông tin quan trọng.
  2. Nếu bạn thực sự, thực sự k muốn bẻ gãy quá trình làm việc bây h, hãy note lại bằng cách viết vài cái TODO hay FIXME vào.

**2.4. Here We Go Again **

Sau khi code đã được refactor, chạy test lại lần nữa và đảm bảo không có mối nguy hại nào. Hãy nhớ chạy tất cả unit test - không phải chỉ 1 cái không. Unit test chạy rất nhanh: gần như không làm mất thời gian của bạn khi chạy tất unit test. Chạy tất mới làm bạn hết lo lắng được.

3. Benefits

TDD giúp chúng ta, nhưng nó không đảm bảo good design & good code. Kỹ năng, tài năng và sự thẩm định vẫn phải được đảm bảo. — Esko Luontola Bây giờ, bạn đã biết câu truyện đằng sau vòng tròn TDD. Trước khi đi đến ví dụ thực tế, hãy list lại những lợi ích của nó:

  • Tất cả code đều có unit test
  • Code đã thỏa mã được test - sẽ không còn đoạn code vô dụng nào cả(YAGNI)
  • Viết một lượng code nhỏ nhất hướng tới nguyên lý KISS
  • Cảm ơn refactor vì nó làm cho code clean và dễ đọc (DRY)
  • Bạn làm gì đó rồi quay lại viết code một cách rất dễ dàng, việc kế tiếp của bạn là đi đến test tiếp theo và bắt đầu chu trình vòng tròn.

4. TDD is Not Only about Unit Tests

Mặc dù cuốn sách này viết về unit test, nhưng section này nhắc nhở bạn TDD là vấn đề rộng lớn cần quan tâm hơn unit test. Thực tế bạn có thể, và bạn được khuyến khích sử dụng TDD ở mọi level.

<< Chuyển thể từ chapter 4: Test Driven Development - Practical Unit Testing with JUnit and Mockito - Tomek Kaczanowski>>