本章的測試都是使用MiniTest來撰寫,目前MiniTest可以在安裝了 Ruby 1.9及以上版本的任何地方執行。
至於筆者在工作上則是使用RSpec搭配Capybara去撰寫測試,大家有興趣的話可以參考Rspec文件和Capybara文件,順便推坑大大寫的鐵人賽文章,就是用使用RSpec搭配Capybara。
輸入訊息構成了物件的公共介面,也就是其他物件能夠溝通的對象。
作者使用第3章的程式碼做以下範例:
Wheel
會冋應一則輸入訊息diameter
,它接著會被Gear
傳送;而Gear
則會回應兩則輸入訊息 gear_inches
和ratio
。(我們的測試主要會在這兩個回傳訊息的部分)
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
在撰寫測試時,我們可以試著畫出或是判斷出上表中的資訊,確認物件的依賴關係,不要測試沒有依賴關係的輸入訊息,而是刪除它。
驗證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
的設計非常清楚易懂,他沒有隱藏依賴,所以在執行獨立測試時,不會涉及到其他的應用程式類別。
驗證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
Gear
的gear_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'
Diameterizable
被Gear
所依賴,並被Wheel
所實作。Diameterizable
角色是一個抽象概念,而程式碼中存在兩種情況,其中,物件依賴於Diameterizable
角色。 Gear
類別認為它知道Diameterizable
的介面,而建立注入對象的程式碼認為Wheel
類別實現了該介面。當Diameterizable
發生變化時,這些依賴關係可能會出現問題。
注入依賴關係的關鍵是,它們允許在不修改現有代碼的情況下替換為不同的具體類別。這種方式可以組裝出新的行為。通常情況下,抽象角色比具體類別更加穩定。我們有些時候會建立虛構角色以符合測試情境,特別是當應用程序包含多個不同的Diameterizable
時。(在BDD(行為驅動開發)中,可能不會存在扮演Diameterizable
角色的對象,因此可能需要創建虛構對像以編寫測試。)
建立測試替身
建立偽物件(或者稱為測試替身,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
脆弱的測試
假設程式碼經歷了一些變化,包括Diameterizable
介面的diameter
變為width
,Wheel
類別被更新,但Gear
類別保持不變。
儘管程式碼實際上已經無效,測試卻仍然通過。這種情況下,測試不再能正確地檢測程式碼中的問題,因為測試和程式碼之間的介面被改變。
這種情況下的問題是由於截短(mocking)和模仿(stubbing)操作導致的。這些操作可能導致測試變得脆弱,因為它們可以隱藏程式碼中的問題,使測試通過,但應用程式仍然無效。當介面發生變化時,所有角色扮演者都必須更新以適應新的介面。
使用測試來記錄角色
這邊指的是,有時角色在應用程式中幾乎難以察覺,因此很容易忘記它的存在,尤其是在沒有明顯的地方定義Diameterizable
時,一種提高角色可見性的方法是通過測試來證實某個對象扮演該角色。
Wheel
斷言它會扮演這個角色,但無法防止Gear
的DiameterDouble
變得過時,也無法阻止 gear_inches
測試錯誤地通過。範例中的implements_the_diameterizable_interface
測試說明@wheel
對象具有diameter
方法,以驗證其扮演Diameterizable
角色。
即便如此,角色測試的一個缺點是它無法與其他扮演相同角色的對象共享。其他扮演同一角色的對象需要複製相同的測試。
當受測物件會傳送訊息給自己,傳送給self
的訊息,會呼叫接收者在私有介面裡所定義的方法。由於私有方法的傳送無法被受測物件的外部所看見,所以它們並不需要被測試。
在測試中忽略私有方法
私有方法會被已經擁有測試的公共方法所呼叫。私有方法裡的錯誤勢必也會破壞整支應用程式,但這種失敗總是能夠藉由某項現有的測試暴露出來。因此測試私有方法完全沒有必要。
從測試類別裡移除私有方法
如果你的物件擁有大量私有方法,會讓設計散發出擁有過多職責的「臭味」,請將這些方法撷取為新的物件。從新物件的核心職責撷取出來的方法可以作為其公共介面。而公共介面 (在理論上)是穩定的,進而可以安全地依賴它們。但不幸的是,只有當新介面確實是穩定的,它才能真正地發揮作用。
選擇測試私有方法
先將有臭味的程式碼放置在一處,隱藏混亂直到有更好的資訊出現,這在某些時候能夠提供防護。將混亂隱藏起來很容易,只需要將有問題的程式碼包裝在某個私有方法裡即可,這麼做的目的同時也是為了減少重構的障礙,先將時間拿來處理會對外揭露的公共介面。
輸出訊息分為 查詢 及 命令 ,查詢訊息只與傳送他們的物件相關,而命令訊息則對於應用程式裡的其他物件有所影響。
只有gear_inches
方法才會關心diameter
是否被傳送,Gear
的唯一責任是要證明gear_inches
可以正常運作,它可以簡單地藉由測試gear_inches
是否總是會傳回適當結果來驗證。
雖然Gear
的gear_inches
方法依賴於diameter
的回傳訊息,但用來證明diameter
正確性的測試則隸屬於Wheel
而不是在Gear
裡。
class Gear
def gear_inches
ratio * wheel.diameter
end
end
假設有一款自行車的競速遊戲。自行車都有齒輪,Gear
類別現在要負責讓應用程式知道玩家更換齒輪的時間,以便讓應用程式可以更新自行車的性能。
增加一個observer
之後,Gear
便滿足了這項新的需求。當某位玩家變換齒輪時,set_cog
方法或 set_chainring
方法會被執行。這些方法會將新值儲存,然後呼叫Gear
的changed
方法,接著,這個方法會將changed
傳送給observer
,同時傳遞目前的chainring
和cog
。
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
有了一項新職責:當chainring
或cog
發生變化時,它必須通知observer
。這項新職責與先前計算齒輪大小的職責一樣重要。當某位玩家修改chainring
或cog
時,只有當Gear
將changed
傳送給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
如何表現的細節上,我們模仿observer
的changed
方法,但並沒有讓它做任何事,並且Gear
仍然可以運作,這件事實證明Gear
並不在意該方法實際上做了什麼,它唯一的職責是傳送這則訊息。
參考資料: