07/09/2018, 16:59

Tìm hiểu về phương pháp lập trình Test Driven Development (part1)

TDD (Test Driven Development) - tức là một phương pháp lập trình chú trọng vào việc test, "viết test trước viết code sau"... TDD tức là "viết test trước khi viết code". Nghĩa là sao? Chưa có code thì làm sao mà test? Đây chính là mấu chốt, khi bạn định implement một function nào đó, bạn sẽ phải ...

TDD (Test Driven Development) - tức là một phương pháp lập trình chú trọng vào việc test, "viết test trước viết code sau"...

TDD tức là "viết test trước khi viết code". Nghĩa là sao? Chưa có code thì làm sao mà test?

Đây chính là mấu chốt, khi bạn định implement một function nào đó, bạn sẽ phải viết một function khác, sử dụng chính cái function bạn định implement, và khi chạy test tất nhiên nó sẽ fail, đơn giản là vì bạn chưa implement cái gì cả. Vì vậy việc tiếp theo là implement để làm cho nó hết fail (pass). Cuối cùng, bỏ chút thời gian ra refactor lại code cho đẹp và gọn gàng hơn.

Và bằng cách suy nghĩ trước mọi tình huống có thể xảy ra khi sử dụng cái function bạn sắp viết, sau khi toàn bộ test đã pass thì bạn có một function hoàn hảo và không còn khả năng xảy ra bug nữa.

Bài toán FizzBuzz

Để minh họa việc áp dụng TDD trong thực tế, mình sẽ áp dụng nó vào việc xây dựng một chương trình đơn giản có tên là FizzBuzz.

Nếu các bạn chưa biết, thì FizzBuzz là một hàm nhận vào một số N và trả về các giá trị khác nhau tùy theo từng điều kiện:

  • Trả về chuỗi "Fizz" nếu số N chia hết cho 3 (ví dụ 3, 6, 9,...)
  • Trả về chuỗi "Buzz" nếu số N chia hết cho 5 (ví dụ 5, 10,...)
  • Trả về chuỗi "FizzBuzz" nếu số N chia hết cho cả 3 và 5 (ví dụ 15, 45,...)
  • Trả về chính con số đó nếu nó không thỏa mãn các điều kiện trên

Sỡ dĩ mình chọn bài toán này để minh họa cho TDD là vì nó cực kì đơn giản mà lại có nhiều case tùy thuộc vào từng input, không quá khó để hiểu và cover được toàn bộ các case trong phạm vi 1 bài viết ngắn như thế này.

Implement bài toán FizzBuzz áp dụng TDD

Rồi, giờ thì chúng ta đã hiểu đề bài, vậy thì bắt tay vào code thôi. Trong bài này mình sẽ sử dụng Ruby và Minitest để implement và test. Nếu lần đầu tiên nghe nói đến 2 cái này thì các bạn chịu khó tìm đọc qua một chút trước khi chúng ta tiếp tục. Nhất là Minitest, còn Ruby thì code của nó cũng không đến nỗi quá phức tạp nên dù bạn code Java hay JavaScript thì đọc vào chắc cũng sẽ hiểu

Chuẩn bị

Tạo một thư mục dành cho project của chúng ta, có thể đặt tên tùy ý, trong đó tạo 3 file lần lượt như sau: Rakefile, fizzbuzz.rbfizzbuzz_test.rb

Rakefile có nhiệm vụ định nghĩa task chạy test, có nội dung như sau:

require "rake/testtask"

Rake::TestTask.new(:test) do |t|
  t.libs << "test"
  t.test_files = FileList["*_test.rb"]
  t.verbose = true
end

task :default => :test

Chúng ta sẽ implement module FizzBuzz, trong module này có 1 hàm run() nhận vào tham số N và trả về các giá trị như đã nói ở phần khái niệm. Đây là phần sườn code cho module FizzBuzz, nằm trong file fizzbuzz.rb

module FizzBuzz
  extend self

  def run(n)

  end
end

Phần code test sẽ đặt trong file fizzbuzz_test.rb và có nội dung như sau:

require "minitest/autorun"
require "./fizzbuzz"

class FizzBuzzTest < Minitest::Test

end

Sau khi tạo xong 3 file trên, các bạn có thể chạy thử test bằng lệnh:

rake test

Output sẽ như thế này:

# Running:



Finished in 0.001125s, 0.0000 runs/s, 0.0000 assertions/s.

0 runs, 0 assertions, 0 failures, 0 errors, 0 skips

Output như trên nghĩa là bạn đã cài đặt thành công chương trình, nhưng chưa có test case nào được chạy.

Quay trở lại bài toán, chúng ta sẽ có bao nhiêu tình huống cần nghĩ ra để test? Bạn thử tự trả lời trước khi đọc tiếp xem?

Câu trả lời là 4. Cũng chính là 4 gạch đầu dòng mô tả hàm FizzBuzz đã ghi ở trên. Tất nhiên nếu máu thì bạn có thể viết nhiều test case hơn cho nó cũng được, nhưng nếu bạn viết quá nhiều test case thì rất có thể bạn đang viết thừa rồi (tức là sẽ có các test case test cùng một vấn đề).

  • Test case #1: Nhận vào một số chia hết cho 3, trả về chuỗi Fizz
  • Test case #2: Nhận vào một số chia hết cho 5, trả về chuỗi Buzz
  • Test case #3: Nhận vào một số cùng chia hết cho 3 và 5, trả về chuỗi FizzBuzz
  • Test case #4: Nhận vào một số không chia hết cho 3 hay 5 gì cả, trả về chính số đó

Giờ chúng ta sẽ bắt tay vào phần chính, test và code.

1.Nhận vào một số chia hết cho 3, trả về chuỗi Fizz

Xem nào, như đã nói ở phần trên kia thì chúng ta sẽ gọi hàm FizzBuzz.run(...), với tham số n truyền vào cho hàm run là một con số chia hết cho 3, ví dụ như là số 6, hoặc 9. Và kết quả thu được sẽ là một chuỗi "Fizz".

Viết test trước...

Vậy thì test case đầu tiên của chúng ta sẽ như thế này:

fizzbuzz_test.rb

def test_fizzbuzz_run_return_fizz
  expect = "Fizz"
  actual = FizzBuzz.run(6)
  assert_equal expect, actual
end

Đặt tên hàm có dạng test_xxx là do Minitest quy định, và để cho rõ ràng thì phần xxx trong tên hàm test chúng ta nên diễn đạt nội dung hoặc mục đích của test case này, ở đây là: FizzBuzz Run Return Fizz, có nghĩa là: Hàm run của module FizzBuzz sẽ trả về nội dung Fizz.

Việc khai báo 2 biến expect và actual là để cho phần code test này dễ đọc hơn thôi, trong thực tế các bạn có thể bỏ nếu không thích. Tuy nhiên tiêu chí là: Code test thì phải luôn luôn rõ ràng.

Lệnh assert_equal cũng là của Minitest, có nhiệm vụ kiểm tra xem giá trị của 2 tham số expect và actual có bằng nhau không. Ngoài ra còn có nhiều phép assertion khác, các bạn có thể đọc thêm tại đây

Giờ chạy test thử nhé, bảo đảm nó bắn lỗi ngay:

# Running:

F

Finished in 0.001543s, 2592.3526 runs/s, 2592.3526 assertions/s.

  1) Failure:
FizzBuzzTest#test_fizzbuzz_run_return_fizz [...]:
Expected: "Fizz"
  Actual: nil

1 runs, 1 assertions, 1 failures, 0 errors, 0 skips
rake aborted!

Nội dung thông báo trên có nghĩa là, kết quả mong đợi là "Fizz" nhưng kết quả thực tế lại là nil (null, vì chưa implement lấy đâu ra có kết quả).

...Viết code sau

Test cũng viết rồi, fail cũng fail luôn rồi, giờ là lúc làm cho cái test kia xanh trở lại (pass), bằng cách implement trường hợp đầu tiên cho hàm run như đoạn code dưới đây:
fizzbuzz.rb

def run(n)
  return "Fizz" 
end

Giờ chạy test lại thử xem nhé:

# Running:

.

Finished in 0.001766s, 566.2514 runs/s, 566.2514 assertions/s.

1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

Ghi vậy tức là pass rồi đó.

Mặc dù điều kiện ở đầu bài là phải chia hết cho 3, nhưng test case đầu tiên chỉ yêu cầu trả về chuỗi "Fizz", nên chúng ta có thể làm test pass bằng mọi giá, tức là không từ một thủ đoạn nào. Nếu nhìn vào code implement các bạn có thể thấy mình làm nó pass bằng một cách rất là thủ đoạn. Tuy nhiên qua test case tiếp theo thì không chơi trò này được nữa. Các bạn lưu ý nhé.

Giờ qua test case thứ 2 nhé.
To be continue....

0