Đây là bài tổng hợp lại kiến thức thu được từ bài speech sau của Sandi Mezt: [https://www.youtube.com/watch?v=URSWYvyc42M](https://www.youtube.com/watch?v=URSWYvyc42M) (Một chút info: Sandi Mezt là một **Nữ Developer** Ruby / Rails nổi tiếng với cuốn sách [Practical Object-Oriented Design in Ruby](http://www.sandimetz.com/products), hiện tại cô đã xuất bản thêm cuốn **99 Bottles of OOP** - cũng rất được mọi người ủng hộ) Bài speech chỉ tập trung vào **Unit Test** với các vấn đề sau : 1. Vì sao mọi người ghét test 2. Mục đích của việc viết unit test 3. Test gì trong unit test 4. Luật cho việc viết unit test 5. Tổng kết ### Vì sao mọi người ghét test Test - đặc biệt là **Unit Test** là một thứ mà chúng ta đều thừa nhận là *cần thiết* nhưng lại *ngại*, đôi khi là *ghét* mỗi khi nghĩ đến. Câu nói *chúng ta thường sợ những gì chúng ta không biết* rất nổi tiếng, nhưng liệu có bao giờ bạn đã từng thắc mắc *vì sao tôi ghét điều gì đó* hay không, đặc biệt là đối với **Unit Test**, cảm giác điều đó là cần thiết nhưng mỗi lần nhắc đến lại không mấy thoải mái chút nào. Và đây là một số nguyên nhân khiến chúng ta nghĩ như vậy : #### Unit Test chậm Tưởng tượng bạn viết một loạt các test cases cho class của mình, sau đó miệt mài implement từng hàm từng hàm, mỗi lần implement xong, bạn lại chạy câu lệnh run test để nhìn vài chấm đỏ chuyển thành chấm xanh. Bạn cảm giác công việc mình đang tiến triển, việc implement đang hoàn thành dần, chúng ta thật tài năng khi đang tạo ra những dòng **working code**. Con người là động vật rất thích thỏa mãn với những thành tựu, do đó việc liên tục chạy test để nhìn thấy thành quả của bản thân là một nhu cầu thiết yếu. Tuy nhiên, nếu thời gian chạy test lâu, việc implement và ngồi chờ test cases chạy sẽ khiến chúng ta có cảm giác *thành công bị trì hoãn* - và điều này thì khá là khó chịu. Cộng thêm thời gian chờ test chạy sẽ khiến hiệu quả công việc, năng suất lao động đi xuống. Và thế là chúng ta ghét như ghét một thứ *kìm nén khả năng lao động của bản thân*. #### Unit Test thật mong manh App của bạn đi vào hoạt động ổn định. Và đến một ngày đẹp trời, bạn chỉ thay đổi một phần xíu xiu trong implement của mình, và app của bạn vẫn hoạt động tốt đẹp, thậm chí còn cải thiện thêm chút xíu. Niềm vui vì đã nâng cấp được app của mình diễn ra chưa lâu thì ... BOOM!, tất cả unit test của bạn đều fail. Vậy là thay vì vui sướng với thành tựu đạt được của mình, bạn phải cắm mặt, hoảng hốt đọc từng dòng đỏ lòm thông báo error để kiểm tra và fix lại unit test. Lần 1, lần 2,... cho đến lần n sửa app, tình huống trên vẫn đều đều xảy ra, và bạn cảm thấy rằng, cái đống test cases này càng ngày càng phiền phức, đỏng đảnh mong manh như một thiếu nữ trong lồng kính. Và thế là chúng ta ghét như ghét một thứ *mong manh, thất thường, khó chiều, phiền phức* Vậy là với một thứ vừa **tốn thời gian, giảm năng suất lao động, mong manh, thất thường, khó chiều, phiền phức** thì việc chúng ta thấy ghét hoặc không có thiện cảm là hoàn toàn dễ hiểu (mô tả ở trên có vẻ giống với định nghĩa về *bạn gái*, tuy nhiên chúng ta không nên bàn nhân chủng học ở đây). Nếu bạn đã trải qua những điều trên (hoặc thông qua người khác dọa dẫm, than phiền), thì đó là lý do bạn nên đọc tiếp bài này. Phần sau đây sẽ giúp chúng ta giải quyết được 2 vấn đề ở trên, khiến cho việc viết unit test sẽ không còn cảm giác là cực hình với developer chúng ta nữa. ### Mục đích của việc viết unit test Cũng giống như *chiều bạn gái* vậy, quen lâu rồi bạn sẽ nắm được tính cách, thói quen để đối phó với tính đỏng đảnh của cô nàng, để giải quyết những vấn đề khi viết unit test, chúng ta sẽ có một số trick (thủ thuật) đơn giản, dễ hiểu, dễ học và áp dụng, nó cứ như là ma thuật vậy. Trước hết, chúng ta cần hiểu mục tiêu cần đạt được đối với unit test là gì? Sau đây là 4 từ cho mục tiêu đó: 1. Thorough: Mỗi unit test sẽ chỉ tập trung vào một đối tượng (single object) của app và đảm bảo đối tượng đó hoạt động đúng như mong đợi. 2. Stable: Unit Test sẽ hoạt động ổn định, không bị hỏng mỗi khi thay đổi code implement chi tiết. 3. Fast: Unit Test cần phải nhanh để có thể kiểm tra các thay đổi nhanh chóng 4. Few: Code cho Unit Test cần ở mức vừa đủ, không thừa để tránh chi phí bảo trì (và đọc nữa). ### Test gì trong unit test Với mục tiêu trên dành cho Unit Test, câu hỏi đặt ra là : Chúng ta sẽ test cái gì với unit test? Bởi Unit Test sẽ tập trung vào hành vi của một đối tượng, nên chúng ta sẽ tập trung vào việc kiểm tra các hành vi đó có hoạt động đúng chưa. Các hành vi được thực hiện thông qua các *message*, nên chúng ta sẽ tập trung vào *message* khi kiểm tra hành vi của một đối tượng. Và sẽ có 3 luồng *message* được mô tả như sau : ![Selection_002.png](/uploads/11e802ec-f6cb-422e-9bd8-95bbf9702af2.png) 1. Incoming: Các message mà đối tượng có thể nhận được từ các đối tượng khác (từ bên ngoài) 2. Outgoing: Các message mà đối tượng có thể gửi đến các đốí tượng khác (ra bên ngoài) 3. Sent to Self: Các message mà đối tượng dùng để gửi trong chính mình. Một message cũng sẽ có 2 loại : 1. Query: trả về một cái gì đó mà không thay đổi bất cứ thứ gì, không tạo ra side-effect (vd điển hình : hàm cộng 2 số nguyên, trả về kết quả của phép tính cộng mà không thay đổi bất kì giá trị nào của đối tượng). 2. Command: không trả về bất cứ thứ gì và thay đổi một vài thứ, có side-effect (vd khi gọi hàm lưu vào cơ sở dữ liệu, đối tượng có sự thay đổi và các đối tượng khác cũng như app có thể thấy sự thay đổi đó). Có một loại message nữa bao gồm cả 2 loại `query` và `command` , ví dụ điển hình là với hàm `pop` được gọi từ một queue, lúc này sẽ có một giá trị trả về là một đối tượng trong queue (`query` type), đồng thời queue đó cũng sẽ được thay đổi, bớt đi một giá trị (`command` type). Vì cách viết test của `query` và `command` sẽ rất khác nhau, do đó chúng ta cần phải biết phân biệt message thuộc loại nào để có hình thức test phù hợp. Nếu loại message gồm 2 loại `query` và `command` thì sao? Rất đơn giản, viết cả 2 test tương ứng với từng loại. ### Luật cho việc viết unit test Như vậy là có 2 loại message và 3 luồng hoạt động, tưng ứng với bảng sau : ![Selection_003.png](/uploads/019062a4-6972-4958-97b6-85225d746779.png) Và nhiệm vụ của chúng ta sẽ là định nghĩa cho từng nguyên tắc test của từng cell trong bảng trên. Trước tiên sẽ là kết quả: ![Selection_004.png](/uploads/515264bb-44e7-4ced-aaa9-f323ae723cc0.png) *MỘT NỬA* trong số các message là *KHÔNG PHẢI TEST*, tương đương với cảm giác chỉ cần làm 1/2 công việc. Sau đây là lý giải cho từng phần : #### Incoming Query Message Vì đây là message chỉ có kết quả trả về mà không gây ra bất kỳ thay đổi nào của đối tượng (không có side-effect), công việc rất đơn giản, chúng ta chỉ test kết quả trả về. Ví dụ có một class Wheel như sau: ```ruby class Wheel attr_reader :rim, :tire def initialize(args) #... end #... def diameter rim + (tire * 2) end ``` Dễ thấy hàm `diameter` chỉ tính toán và đưa ra kết quả dựa trên thuộc tính của `wheel` mà không thay đổi bất cứ thuộc tính của bất kỳ object nào, nên nó sẽ là `query` type. Do đó test case chỉ đơn giản là kiểm tra giá trị trả về (cell trên cùng bên trái : `assert result`): ```ruby class WheelTest < MiniTest::Unit::Testcase def test_calculates_diameter wheel = Wheel.new 26,1.5 assert_in_delta 29, wheel.diameter, 0.01 end end ``` Rule đầu tiên được phát biểu như sau : `Test incoming query messages by making assertions about what they send back` Ngoài ra, chúng ta sẽ có thể gặp trường hợp phức tạp hơn của `query` message. Ví dụ với lớp `Wheel` ở trên, chúng ta có thêm một lớp `Gear` nữa được mô tả như sau: ```ruby class Gear attr_reader :chainring, :cog, :wheel def initialize(args) #... end #... def gear_inches ratio * wheel.diameter end private def ratio chainring / cog.to_f end #... ``` Hàm `gear_inches` cũng sẽ đóng vai trò như một `incoming query message` nên test sẽ như sau: ```ruby class GearTest < MiniTest::Unit:TestCase def test_calculates_gear_inches gear = Gear.new( chainring: 52, cog: 11, wheel: Wheel.new(26,1.5)) assert_in_delta(137.1, gear.gear_inches, 0.01) end end ``` Mặc dù trong phần implement có gọi sang một đối tượng `Wheel`, chúng ta hay băn khoăn có phải viết test cho phần `ratio`, hay thậm chí là `wheel.diameter` hay không? - Short answer: **KHÔNG** - Long answer: bởi vì hàm `gear_inches` không thay đổi bất kỳ thuộc tính, giá trị của bất kỳ đối tượng nào, nên chỉ cần kiểm tra kết quả trả về là đủ, không cần quan tâm `hàm đó làm như thế nào`, mà quan tâm đến `cái nhận về là gì` (Test the interface, not the implementation). Miễn là hàm `gear_inches` còn trả về đúng giá trị như mong đợi, thì việc thay đổi implementation của hàm đó sẽ không bao giờ khiến test case này fail. #### Incoming Command Message Giờ lớp Gear sẽ có một hàm `set_cog` như sau: ```ruby class Gear attr_reader :chainring, :cog, :wheel def initialize(args) #... end #... def set_cog(new_cog) @cog = new_cog end ``` Hàm `set_cog` khiến cho giá trị cuả thuộc tính từ thời điểm gọi hàm thay đổi, và khi truy cập đến thuộc tính `cog` của đối tượng `Gear` vừa gọi, tất cả các đối tượng khác đều nhận thấy sự thay đổi này, do đó nó là `incoming command message`. Để test hàm này ta làm như sau : ```ruby class GearTest < MiniTest::Unit:TestCase def test_set_cog gear = Gear.new gear.set_cog(27) assert_in_delta(27, gear.cog) end end ``` Chúng ta chỉ test giá trị được thay đổi (ở đây là `cog`), và rule cho test incoming command message như sau: `Test incoming command messages by making assertions about direct public side effect` `Direct public side effect` ở đây được hiểu là một side-effect nhận thấy được mà đối tượng của side-effect chính là đối tượng đang viết test(ở đây là thuộc tính `cog` của chính đối tượng `Gear`) #### Sent to Seft Message Quay trở lại với hàm Gear có method private như sau : ```ruby class Gear attr_reader :chainring, :cog, :wheel def initialize(args) #... end #... def gear_inches ratio * wheel.diameter end private def ratio chainring / cog.to_f end #... ``` Chúng ta có thể sẽ có ham muốn test hàm `ratio` bằng 1 trong 2 cách sau : Kiểm tra giá trị trả về của hàm `ratio`: ```ruby class GearTest < MiniTest::Unit:TestCase def test_calculates_gear_inches gear = Gear.new( chainring: 52, cog: 11, wheel: Wheel.new(26,1.5)) assert_in_delta(4.7, gear.ratio, 0.01) end end ``` Kiểm tra hàm `ratio` được gọi khi thực hiện hàm `gear_inches`: ```ruby class GearTest < MiniTest::Unit:TestCase def test_calculates_gear_inches gear = Gear.new( chainring: 52, cog: 11, wheel: Wheel.new(26,1.5)) assert_in_delta(137.1, gear.gear_inches, 0.01) gear.expect(:ratio) gear.verify end end ``` Và comment cho 2 cách ở trên đều là **THỪA**. Bởi hàm `gear_inches` đã được test giá trị ở trên, việc test giá trị cho hàm ratio không cần thiết nữa, vì khi hàm `gear_inches` đã đúng, đương nhiên hàm `ratio` cũng phải hoạt động đúng. Hoặc nếu kiểm tra `gear` có gọi đến hàm `ratio` bằng message, chúng ta lại sa vào việc kiểm tra `hàm đó làm như thế nào`, khiến test bám chặt vào cách implement hiện tại, nếu sau này đổi implement hàm `gear_inches` không dùng ratio nữa (refactor), test case cho việc `expect` chắc chắn sai -> tốn cost thay đổi mà không đem lại lợi ích gì. Điều này áp dụng cho tất cả private method, tương ứng với tất cả message dạng `send to self`, khiến chúng ta có rule tiếp theo: `Do not test private methods. Do not make assertions about their result. Do not expect to send them` #### Outgoing Query Message Làm tương tự với `send to self` message : Ignore them, no test. Giải thích : Vẫn với ví dụ về hàm `gear_inches` ở trên, lúc này chúng ta có một đối tượng `Wheel` được gọi với hàm `diameter`, do đó sẽ có một `Outgoing Query Message` từ đối tượng `Gear` đến đối tượng `Wheel` nên chúng ta có thể sẽ viết test như sau : ```ruby class GearTest < MiniTest::Unit:TestCase def test_calculates_gear_inches gear = Gear.new( chainring: 52, cog: 11, wheel: Wheel.new(26,1.5)) assert_in_delta(137.1, gear.gear_inches, 0.01) assert_in_delta(29, gear.wheel.diameter, 0.01) end end ``` Có thể nhận thấy là dòng test `assert_in_delta(29, gear.wheel.diameter, 0.01)` sẽ hoàn toàn tương ứng với việc test `incoming query message` ở lớp `Wheel`, và chúng ta có thể giảm bớt ở đây mà vẫn yên tâm, vì nó sẽ được test ở lớp `Wheel`. Hoặc là chúng ta sẽ dùng `expect`: ```ruby class GearTest < MiniTest::Unit:TestCase def test_calculates_gear_inches gear = Gear.new( chainring: 52, cog: 11, wheel: Wheel.new(26,1.5)) assert_in_delta(137.1, gear.gear_inches, 0.01) gear.wheel.expect(:diameter) gear.verify end end ``` Việc này một lần nữa lại khiến test case của chúng ta đi vào chi tiết tìm hiểu `hàm gear_inches làm như thế nào`, và test case sẽ dính chặt với implementation, dễ fail khi implement thay đổi, cũng như không chứng minh được bất kì điều gì cả (với hàm `gear_inches`, ta chỉ quan tâm là kết quả trả về có như mong đợi không, do không có bất kỳ side-effect nào). Rule tiếp theo: `Do not test outgoing query message. Do not make assertions about their result. Do not expect to send them` #### Outgoing Command Message Với ví dụ về lớp `Gear`, giờ chúng ta sẽ có thêm một thuộc tính `:observer` để theo dõi các thay đổi như sau : ```ruby class Gear attr_reader :chainring, :cog, :wheel, :observer def initialize(args) #... @observer = args[:observer] end def set_cog(new_cog) @cog = new_cog changed @cog end def changed observer.changed(chainring, cog) end ``` Chúng ta sẽ coi hàm `changed` của đối tượng `observer` có side-effect(vd như cập nhật dữ liệu vào db của đối tượng observer, khiến cho các đối tượng khác nhận biết được sự thay đổi), do đó message `set_cog` cũng thuộc loại `outgoing command query` (biểu đồ dưới đây mô tả rõ hơn): ![Selection_005.png](/uploads/e6bca182-c52d-473a-b2a1-bb4382828e07.png) Bởi vì đối tượng `observer` có side-effect, nên chúng ta có thể sẽ nghĩ rằng cần test side-effect đó như này: ```ruby class GearTest < MiniTest::Unit:TestCase def test_saves_changed_cog_in_db @observer = Obs.new @gear = Gear.new( chainring: 52, cog: 11, observer: @observer) @gear.set_cog(27) # Một vài đoạn code kiểm tra trạng thái db để chứng tỏ observer được update end end ``` Câu hỏi ở đây là : Lớp `Gear` có trách nhiệm phải thay đổi data của lớp `Observer` hay không? Câu trả lời là **Không**, nó là side-effect của lớp `Obs`, do đó khi viết test như trên, chúng ta đã tạo ra một phụ thuộc (dependency) giữa lớp `Obs` và lớp `Gear` và test phụ thuộc đó -> trở thành integration test. Thay vào đó chúng ta chỉ cần test là đối tượng `Obs` sẽ nhận được notify khi `cog` được set: ```ruby class GearTest < MiniTest::Unit:TestCase def test_notifies_observers_when_cogs_change @observer = MiniTest::Mock.new @gear = Gear.new( chainring: 52, cog: 11, observer: @observer) @observer.expect(:changed, true, [52, 27]) @gear.set_cog(27) @observer.veriry end end ``` Lớp `Gear` chỉ có trách nhiệm là gửi message `changed` đến đối tượng `observer`, nếu chừng nào lớp `Gear` còn làm đúng trách nhiệm đó, test case ở trên vẫn phải hoạt động đúng. Do đó chúng ta dùng `Mock` cho đối tượng `@observer`. Nếu lớp `Gear` đổi observer sang một đối tượng khác ko thuộc lớp `Obs`, nhưng vẫn có hàm `changed`, lúc này test case ở trên vẫn đúng, không cần thay đổi. Tuy nhiên chúng ta cần cẩn thận với `Mock`, vì nếu `Obs` ngừng không implement hàm `changed` nữa, test case vẫn pass nhưng app sẽ bị chết ở phần `set_cog`. Lúc này mặc dù tất cả test cases passed nhưng app vẫn failed, lúc đó sẽ thực sự là `Pain in the A**`. Để ngăn chặn việc trên, chúng ta cần phải check rằng đối tượng `observer` cũng sẽ có hàm `changed`, có thể làm bằng tay hoặc một số cách sau: ![Selection_006.png](/uploads/31c07d5b-892f-498f-9b29-b0255b82a836.png) Rule: `Expect to send outgoing command messages` ### Tổng kết - Khi viết Unit Test, chúng ta sẽ tập trung vào message - Bảng tham chiếu khi gặp từng loại message: ![Selection_004.png](/uploads/515264bb-44e7-4ced-aaa9-f323ae723cc0.png) - Rule khi viết Unit Test: - `Test incoming query messages by making assertions about what they send back` - `Test incoming command messages by making assertions about direct public side effect` - `Do not test private methods. Do not make assertions about their result. Do not expect to send them` - `Do not test outgoing query message. Do not make assertions about their result. Do not expect to send them` - `Expect to send outgoing command messages` - Cẩn thận với việc viết Mock và Stub - Sau khi đọc hiểu những điều trên, bắt đầu viết Unit Test, ăn hành với nó, rút kinh nghiệm với nó, và yêu nó (lol)