12/08/2018, 13:36

Những điều lưu ý khi viết Unit Test - The Magic Tricks of Testing

Đâ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 (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, hiện tại cô đã xuất bản thêm cuốn 99 Bottles ...

Đâ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

(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, 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

  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

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

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:

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):

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:

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:

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:

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 :

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 :

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:

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:

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 :

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:

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 :

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

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:

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:

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

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

  • 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)

0