iT邦幫忙

2023 iThome 鐵人賽

DAY 19
0
自我挑戰組

入坑 RoR 必讀 - Ruby 物件導向設計實踐系列 第 19

Day19 CH9 設計節省成本的測試(中)

  • 分享至 

  • xImage
  •  

本章的測試都是使用MiniTest來撰寫,目前MiniTest可以在安裝了 Ruby 1.9及以上版本的任何地方執行。

至於筆者在工作上則是使用RSpec搭配Capybara去撰寫測試,大家有興趣的話可以參考Rspec文件Capybara文件,順便推坑大大寫的鐵人賽文章,就是用使用RSpec搭配Capybara。

測試輸入訊息

輸入訊息構成了物件的公共介面,也就是其他物件能夠溝通的對象。
作者使用第3章的程式碼做以下範例:

Wheel會冋應一則輸入訊息diameter,它接著會被Gear傳送;而Gear則會回應兩則輸入訊息 gear_inchesratio。(我們的測試主要會在這兩個回傳訊息的部分)

class Wheel
  attr_reader :rim, :tire
  def initialize(rim, tire)
    @rim = rim
    @tire = tire
  end

  def diameter
    rim + (tire * 2)
  end
# ...
end

class Gear
  attr_reader :chainring, :cog, :rim, :tire
	def initialize(args)
	  @chainring = args [:chainring]
	  @cog = args [:cog]
	  @rim = args [:rim] 
	  @tire = args [:tire] 
	end

	def gear_inches
	  ratio * Wheel.new(rim, tire).diameter 
	end
	
	def ratio
	  chainring / cog.to_f
	end
#...
end

刪除未使用介面

https://ithelp.ithome.com.tw/upload/images/20230919/20145409KhwTQhEEMO.jpg

在撰寫測試時,我們可以試著畫出或是判斷出上表中的資訊,確認物件的依賴關係,不要測試沒有依賴關係的輸入訊息,而是刪除它。

驗證公共介面

  1. 驗證diameter

    手法:測試輸入訊息可以藉由對它們所傳回的值或狀態進行確認來實作。測試輸入訊息的第一項要求是要證明它在所有可能情況下都會傳回正確的值。
    實作:第4行建立了一個Wheel實體,而第6行則確認這個Wheel的diameter為29。

    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
    

    Wheel的設計非常清楚易懂,他沒有隱藏依賴,所以在執行獨立測試時,不會涉及到其他的應用程式類別。

  2. 驗證gear_inches

     class GearTest < MiniTest::Unit::TestCase
    
       def test_calculates_gear_inches
         gear = Gear. new ( 
           chainring: 52,
           cog: 11,
           rim: 26,
           tire: 1.5)
    
         assert_in_delta(137.1,
           gear.gear_inches,
           0.01)
       end
     end
    

    Geargear_inches實作會無條件地建立並使用另一個Wheel物件,由於產生問題的耦合是隱藏在Gear內部,因此在這項測試裡它是完全不可見的,因為這項測試的目的只在於gear.inches會傳回正確的結果,忽略掉隱藏風險,因此,我們必須做一些處理。

隔離受測物件

藉由將Wheel的建立從Gear移除的作法,去除了這個綁定。Gear現在會期望被注入一個可以理解diameter的物件,這個diameter方法是某個角色的一部分公共介面, 它可以合理地被命名為Diameterizable

class Gear
  attr_reader :chainring, :cog, :wheel 
  def initialize(args)
	@chainring = args[:chainring] 
	@cog = args[:cog]
	@wheel = args[:wheel]
  end

  def gear_inches
	# 變數「wheel」裡的物件
	# 會扮演 r Diameterizable j 角色。
	ratio * wheel.diameter
  end

  def ratio
	chainring / cog.to_f
  end
# ...
end

同步調整我們的測試,根據程式碼的變化,在測試期間注入Wheel實體,如此一來,在建立的過程中,Gear都是使用Wheel。類別之間原本隱藏的耦合,現在已公開暴露出來。

class GearTest < MiniTest::Uhit::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

注入使用類別的依賴關係

假設Diameterizable的公共介面發生變化。另外有一名程式設計師進入Wheel類別,並將diameter方法重新命名為width(上方程式碼),但卻忘了更新在Gear裡那則傳送訊息的名稱,Wheel實作的是width,但Gear傳送的是diameter,因此該測試現在會失敗。(下方程式碼)

class Wheel

	attr_reader :rim, :tire 
	def initialize(rim, tire)
		@rim= rim 
		@tire = tire
	end

	def width # <-過去是 diameter 
		rim + (tire * 2)
	end
# ...
end
class Gear
	def gear_inches
		ratio * wheel.diameter
	end 
end

#此時 Gear 測試注入一個 Wheel 實例,Wheel 實作的是 width,但 Gear 傳送的是 diameter因此該測試現在會失敗:
Gear
	ERROR test_calculates_gear_inches 
				undefined method 'diameter'

注入使用角色的依賴關係

DiameterizableGear所依賴,並被Wheel所實作。Diameterizable角色是一個抽象概念,而程式碼中存在兩種情況,其中,物件依賴於Diameterizable角色。 Gear類別認為它知道Diameterizable的介面,而建立注入對象的程式碼認為Wheel類別實現了該介面。當Diameterizable發生變化時,這些依賴關係可能會出現問題。

注入依賴關係的關鍵是,它們允許在不修改現有代碼的情況下替換為不同的具體類別。這種方式可以組裝出新的行為。通常情況下,抽象角色比具體類別更加穩定。我們有些時候會建立虛構角色以符合測試情境,特別是當應用程序包含多個不同的Diameterizable時。(在BDD(行為驅動開發)中,可能不會存在扮演Diameterizable角色的對象,因此可能需要創建虛構對像以編寫測試。)

  1. 建立測試替身
    建立偽物件(或者稱為測試替身,test double)來扮演Diameterizable,測試替身是角色扮演者的樣式化實例,專門用於測試。

    #建立「Diameterizable」的扮演者 
    class DiameterDouble
      def diameter
        10
      end 
    end 
    
    class GearTest < MiniTest::UHit::TestCase 
      def test_calculates_gear_inches
        gear = Gear.new( 
          chainring: 52, 
          cog: 11,
          wheel: DiameterDouble.new)
        assert_in_delta(47.27, 
          gear.gear_inches,
          0.01)
      end
    end
    
    GearTest
        PASS test_calculates_gear_inches
    
  2. 脆弱的測試
    假設程式碼經歷了一些變化,包括Diameterizable介面的diameter變為widthWheel類別被更新,但Gear類別保持不變。

    儘管程式碼實際上已經無效,測試卻仍然通過。這種情況下,測試不再能正確地檢測程式碼中的問題,因為測試和程式碼之間的介面被改變。

    這種情況下的問題是由於截短(mocking)和模仿(stubbing)操作導致的。這些操作可能導致測試變得脆弱,因為它們可以隱藏程式碼中的問題,使測試通過,但應用程式仍然無效。當介面發生變化時,所有角色扮演者都必須更新以適應新的介面。

  3. 使用測試來記錄角色
    這邊指的是,有時角色在應用程式中幾乎難以察覺,因此很容易忘記它的存在,尤其是在沒有明顯的地方定義Diameterizable時,一種提高角色可見性的方法是通過測試來證實某個對象扮演該角色。

    Wheel斷言它會扮演這個角色,但無法防止GearDiameterDouble變得過時,也無法阻止 gear_inches測試錯誤地通過。範例中的implements_the_diameterizable_interface測試說明@wheel對象具有diameter方法,以驗證其扮演Diameterizable角色。

    即便如此,角色測試的一個缺點是它無法與其他扮演相同角色的對象共享。其他扮演同一角色的對象需要複製相同的測試。

測試私有方法

當受測物件會傳送訊息給自己,傳送給self的訊息,會呼叫接收者在私有介面裡所定義的方法。由於私有方法的傳送無法被受測物件的外部所看見,所以它們並不需要被測試。

在測試中忽略私有方法
私有方法會被已經擁有測試的公共方法所呼叫。私有方法裡的錯誤勢必也會破壞整支應用程式,但這種失敗總是能夠藉由某項現有的測試暴露出來。因此測試私有方法完全沒有必要。

從測試類別裡移除私有方法
如果你的物件擁有大量私有方法,會讓設計散發出擁有過多職責的「臭味」,請將這些方法撷取為新的物件。從新物件的核心職責撷取出來的方法可以作為其公共介面。而公共介面 (在理論上)是穩定的,進而可以安全地依賴它們。但不幸的是,只有當新介面確實是穩定的,它才能真正地發揮作用。

選擇測試私有方法
先將有臭味的程式碼放置在一處,隱藏混亂直到有更好的資訊出現,這在某些時候能夠提供防護。將混亂隱藏起來很容易,只需要將有問題的程式碼包裝在某個私有方法裡即可,這麼做的目的同時也是為了減少重構的障礙,先將時間拿來處理會對外揭露的公共介面。

測試輸出訊息

輸出訊息分為 查詢命令 ,查詢訊息只與傳送他們的物件相關,而命令訊息則對於應用程式裡的其他物件有所影響。

忽略查詢訊息

只有gear_inches方法才會關心diameter是否被傳送,Gear的唯一責任是要證明gear_inches可以正常運作,它可以簡單地藉由測試gear_inches是否總是會傳回適當結果來驗證。

雖然Geargear_inches方法依賴於diameter的回傳訊息,但用來證明diameter正確性的測試則隸屬於Wheel而不是在Gear裡。

class Gear
  def gear_inches
	ratio * wheel.diameter
  end 
end

證明命令訊息

假設有一款自行車的競速遊戲。自行車都有齒輪,Gear類別現在要負責讓應用程式知道玩家更換齒輪的時間,以便讓應用程式可以更新自行車的性能。

增加一個observer之後,Gear便滿足了這項新的需求。當某位玩家變換齒輪時,set_cog方法或 set_chainring方法會被執行。這些方法會將新值儲存,然後呼叫Gearchanged方法,接著,這個方法會將changed傳送給observer,同時傳遞目前的chainringcog

class Gear
  attr_reader :chainring, :cog, :wheel, :observer 
  def initialize(args)
    @observer = args[:observer] 
  end
 
  def set_cog(new_cog)
    @cog = new_cog
    changed
  end

  def set_chainring(new_chainring)
    @chainring = new_chainring
    changed
  end

  def changed
    observer.changed(chainring, cog)
  end
#...
end

Gear有了一項新職責:當chainringcog發生變化時,它必須通知observer。這項新職責與先前計算齒輪大小的職責一樣重要。當某位玩家修改chainringcog時,只有當Gearchanged傳送給observer 這支應用程式才會是正確的,而測試應該要證明這則訊息被傳送了。

class GearTest < MiniTest::Uiiit::TestCase
 
  def setup
    @observer = MiniTest: :Mock.new 
    @gear = Gear.new(
      chainring: 52,
      cog: 11,
      observer: @observer)
  end

  def test_notifies_observers__when_cogs_change 
    @observer.expect (: changed, true, [52, 27]) 
    @gear.set_cog(27)
    @observer.verify
  end

  def test_notifies_observers_when_chainrings_change 
    @observer.expect(:changed, true, [42, 11])
    @gear.set_chainring(42)
    @observer.verify
  end
end

我們利用 模仿 的方式,確認Gear會將changed傳送給observer,並且還不會迫使你去檢查傳回的內容。這裡的模仿目的是要定義一項期望:會有一則訊息被傳送。

notifies_observers_when_cogs_change測試裡,第12行會告知模仿物件所應期待的訊息,第13行則會觸發能夠滿足期望的行為,然後在第14行則要求模仿物件確認它是正確的。

此測試的正確證明了Gear會履行其職責,並且Gear沒有將自己綁定到關於observer如何表現的細節上,我們模仿observerchanged方法,但並沒有讓它做任何事,並且Gear仍然可以運作,這件事實證明Gear並不在意該方法實際上做了什麼,它唯一的職責是傳送這則訊息。

參考資料:

  • Practical Object-Oriented Design in Ruby: An Agile Primer

上一篇
Day18 CH9 設計節省成本的測試(上)
下一篇
Day20 CH9 設計節省成本的測試(下)
系列文
入坑 RoR 必讀 - Ruby 物件導向設計實踐30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言