TDD qua ví dụ thực tế
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",... rất nhiều người đã thử tìm hiểu về TDD và đều đọc được những định nghĩa như thế này trong các bài viết, nhưng cuối cùng khi đọc xong thì vẫn không hiểu nổi TDD là gì.
Một trong những câu hỏi được đặt ra nhiều nhất khi nghiên cứu về TDD có lẽ là: Áp dụng TDD như thế nào trong thực tế?
Chuẩn bị hành lý
Rõ ràng khi bắt đầu code thì phải setup những thứ như là cấu trúc thư mục blah blah. Nhưng phần này không liên quan đến TDD, nên mình sẽ nói lướt qua. Nếu các bạn không hiểu thì có thể hỏi trong phần comment nhé.
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.rb và FizzBuzz_test.rb
Rakefile có nhiệm vụ định nghĩa task chạy test, có nội dung như sau:
require "rake/testtask" Rake::TestTak.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 # hoặc là rake
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.
Giờ chúng ta sẽ bắt tay vào phần chính, test và code.
Chờ chút!
À, mà trước khi bắt đầu thì cũng nên nhắc lại một tí về TDD. 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, để lỡ có bị ai đọc vào thì họ cũng khôi có chửi được mình =))))
FizzBuzz_test.rb
def test_fizzbuzz_run_return_fizzbuzz expect = "FizzBuzz" actual = FizzBuzz.run(15) assert_equal expect, actual
end
Chạy cho nó fail nào...
# Running: .F. Finished in 0.001382s, 2170.7670 runs/s, 2170.7670 assertions/s. 1) Failure:
FizzBuzzTest#test_fizzbuzz_run_return_fizzbuzz [...]:
Expected: "FizzBuzz" Actual: "Fizz" 3 runs, 3 assertions, 1 failures, 0 errors, 0 skips
rake aborted!
OK lỗi rồi. Giờ implement nào, mà khoan, sao lần này giá trị actual không phải nil mà là Fizz nhỉ. Thôi kệ. Bỏ qua.
...Viết code sau
Vậy là giờ chúng ta phải implement cho trường hợp cùng chia hết cho cả 3 và 5. Làm như thế nào nhỉ?
FizzBuzz.rb
def run(n) if n % 3 == 0 return "Fizz" elsif n % 5 == 0 return "Buzz" elsif n % 3 == 0 && n % 5 == 0 return "FizzBuzz" end
end
Ngon lành chưa? Hmm, hình như có gì đó ko đúng lắm, thôi kệ, chắc lo xa quá thôi, test nào.
# Running: .F. Finished in 0.001588s, 1889.1688 runs/s, 1889.1688 assertions/s. 1) Failure:
FizzBuzzTest#test_fizzbuzz_run_return_fizzbuzz [/Users/huy/Desktop/Code/rbfizzbuzz/FizzBuzz_test.rb:20]:
Expected: "FizzBuzz" Actual: "Fizz" 3 runs, 3 assertions, 1 failures, 0 errors, 0 skips
rake aborted!
Ớ, vẫn lỗi. Sao kì zẩy, rõ ràng là fail nè. Implement rồi mà fail là sao ta. Xem nào, nó fail vì actual trả về "Fizz", kì lạ... Có gì đó sai rồi, cùng xem lại code nào.
Đầu tiên chúng ta check coi số n có chia hết cho 3 không n % 3 == 0
, sau đó check coi n có chia hết cho 5 không n % 5 == 0
. À đúng rồi, vậy là nó hốt luôn cả 2 trường hợp chia hết cho 3 và 5 rồi, cho nên trường hợp cùng chia hết cho cả 3 và 5 thì không thể nào mà nằm ở dưới cùng như vậy được.
Vậy cách giải quyết là ngay từ đầu chúng ta phải kiểm tra trường hợp cùng chia hết cho 3 và 5 trước.
FizzBuzz.rb
def run(n) if n % 3 == 0 && n % 5 == 0 return "FizzBuzz" elsif n % 3 == 0 return "Fizz" elsif n % 5 == 0 return "Buzz" end
end
Đó, làm vậy mới đúng, giờ chạy test lại coi nào:
# Running: ... Finished in 0.001329s, 2257.3363 runs/s, 2257.3363 assertions/s. 3 runs, 3 assertions, 0 failures, 0 errors, 0 skips
Đó! Pass rồi thấy chưa? Vừa rồi không phải là tui gà đâu nha, tui cố tình sai cho mấy bạn khỏi bị mất tập trung đó nhớ đó nha, không phải do tui gà đâu đó
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ố đó
Test case cuối cùng này thì nhẹ nhàng thôi, không có trap gì giống như khúc trên nữa đâu, ứ yên tâm mà test.
Viết test trước...
Trong trường hợp này, input có thể sẽ là 2, 4, 8 gì đó, miễn là nó không chia hết cho thằng nào trong 2 số 3 và 5 là được.
FizzBuzz_test.rb
def test_fizzbuzz_run_return_n expect = 8 actual = FizzBuzz.run(8) assert_equal expect, actual
end
Test sẽ fail, nếu không fail thì cần phải xem lại
# Running: ..F. Finished in 0.001451s, 2756.7195 runs/s, 2756.7195 assertions/s. 1) Failure:
FizzBuzzTest#test_fizzbuzz_run_return_n [/Users/huy/Desktop/Code/rbfizzbuzz/FizzBuzz_test.rb:26]:
Expected: 8 Actual: nil 4 runs, 4 assertions, 1 failures, 0 errors, 0 skips
rake aborted!
...Viết code sau
Rồi, giờ thì implement cho trường hợp cuối cùng, code nhanh còn đi cà phê chớ. Mình có cái tật đang code mà ai rủ rê đi cà phê uống nước là bực lắm, tại nghe rủ là đứng dậy đi liền à...
FizzBuzz.rb
def run(n) if n % 3 == 0 && n % 5 == 0 return "FizzBuzz" elsif n % 3 == 0 return "Fizz" elsif n % 5 == 0 return "Buzz" else return n end
end
Test luôn:
# Running: .... Finished in 0.001397s, 2863.2785 runs/s, 2863.2785 assertions/s. 4 runs, 4 assertions, 0 failures, 0 errors, 0 skips
Toẹt zời! Pass hết luôn rồi!!!
Kết luận
Đến đây, các bạn đã implement xong một module FizzBuzz hoàn chỉnh, với đầy đủ mọi test case cần thiết để đảm bảo module luôn chạy đúng và không xảy ra bug tiềm ẩn. Cho nên trong quá trình sử dụng module này trong dự án, nếu có xảy ra lỗi thì các bạn có thể chạy lại test để đảm bảo vấn đề không nằm trong này. Vậy là tiết kiệm được một chút thời gian khi debug.
Các bạn có thể tham khảo source đầy đủ của module FizzBuzz trong bài tại đây
Để áp dụng TDD một cách hiệu quả thì bạn cần phải nắm rõ được yêu cầu, chia được vấn đề cần xử lý thành các vấn đề nhỏ hơn (từng test case), điều này đòi hỏi bạn phải tốn một khoản thời gian ban đầu để suy nghĩ về việc mình cần làm trước khi thực sự bắt tay vào code. Nhưng lợi ích mà nó đem lại thì rất nhiều, giống như việc bạn đi vào rừng mà không sợ bị lạc vì đã có sẵn tấm bản đồ ngồi tự vẽ từ đêm hôm qua vậy đó
Vậy thì, nên lựa chọn giữa tốc độ (bay vào code ào ào) hay chất lượng (uống miếng nước ăn miếng bánh, viết miếng test case trước rồi mới code)? Bỏ thời gian ra ngồi debug và cãi nhau với tester hay nhận bug về chạy test để biết được vấn đề nằm ở đâu rồi fix một cách nhanh chóng? Trở thành một lập trình viên chuyên nghiệp hay một công nhân fix bug, fix vài năm rồi lên làm leader ? Hy vọng đến đây các bạn đã có câu trả lời cho chính bản thân mình.
Happy bug fixing ^^