寫一個可變動的程式需要三個技巧:
測試最重要的功能是減少錯誤與提供文件,而在寫程式之前先寫測試則可以用來改善設計。但更重要的是要有效率,沒有效率的測試會花更多的時間而降低寫測試的意願。
寫測試的好處:
我們的目標是獲得測試所有的好處,但同時要盡可能的降低寫測試的成本,而寫出一個低耦合的測試是一個最好達到的目標。
大部分的人都寫太多的測試,除了造成太多的本成在撰寫不必要的測試,但時也會造成太多過時的測試而最終放棄測試。一個最容易改善測試的方式就是減少測試,而最安全的做法就是同一段程式只需要測試一次,而且是在適合的地方做測試。
刪除重複的測試可以降低當程式變動時測試也要跟著變動的成本,而將測試放在正確的地方可以確保測試只有在必要的時候才需要更改。而要寫出好的測試需要知道你要測的是什麼,我們可以用一些之前提到的設計原則來幫助我們寫測試。
好的物件具有高度的封裝性,我們可以專注於它提供的外部界面而不用去了解物件內部是怎麼做的。而測試可以視做另一個物件,這個物件會使用我們所要測試的目標物件。所以當測試與程式耦合程度越高,測試就越容易因為程式變動而改變。
測試應該針對外部界面而寫,大多數沒有效率的測試都是因為涉及太多的內部實作細節。測試應該要專注在物件接收與送出的訊息,接收的訊息即是這個物件的外部界面,而送出的訊息會成為其它物件所要接收的訊息。
我們根據物件的外部界面傳入對應的訊息,而物件會回傳一個處理過的值,我們用這個回傳值與我們預期的值做比對,我們把這樣比對的結果稱做測試的「狀態」。一個測試的原則就是我們應該只針對物件本身提供的外部界面(接收的訊息)測試它回傳的結果,如果是送出的訊息我們應該只需要測試訊息確實有送出,而不要測試非目標物件的回傳的結果。
你應該要先寫測試。先寫測試強迫我們在設計物件的初期就要思考怎麼做到重複使用這個物件,不然測試會很難寫。但要注意的是,先寫測試不能保證可以得到良好設計的程式碼,改善程式的可重複使用性與真正好的設計仍是有一段差距。好的程式是容易改變的,而好的測試不會隨著程式改變而改變。
當然你可以寫一個自己的測試框架,不過使用主流框架的好處是有很好的支援,能隨時保持更新,而且還可以看到很多有經驗的人如何使用它。測試的框架目前你可以選擇使用MiniTest或是Rspec。除了框架之外,我們還需要選擇測試的型式,TDD(Test Driven Development)或BDD(Behavoir Driven Development)。這兩種型式都是先寫測試,BDD是由外而內的方式,根據需求先建立所需的物件,使用mocking的方式來補足尚未實作的物件。TDD則是由內而外,從domain物件開始測試。
在寫測試的時候,應該要把物件分成兩類,一類是你正在測試的目標物件,另一類是其它的物件。你的測試應該會知道有關目標物件的一些資訊,但要盡量忽略其它的物件,甚至假裝它們都不明確。
接收的訊息即是物件的外部界面。
刪掉沒有用到的外部界面,它不會帶來任何價值但反而增加測試的成本,你不應該實作一個完全不會用到的method。
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
上面的gear_iches,我們可以寫測試如下:
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_inches時,實際上在Gear中會去初始化一個wheel。這裡就會出現相依性帶來的問題:
這時候我們可以發現測試會揭漏設計上的問題,如果物件之間有太多相依性,則在跑測試時就會用到越多的物件,也意味著會花更多的時間。
當我們沒辦法只單獨測試某個物件,就表示有可能有相依性的問題存在。下面是一個用injection改良後的Gear
class Gear
attr_reader :chainring, :cog, :wheel
def initialize(args)
@chainring = args[:chainring]
@cog = args[:cog]
@wheel = args[:wheel]
end
def gear_inches
# The object in the'wheel' variable # plays the 'Diameterizable' role.
ratio * wheel.diameter
end
def ratio
chainring / cog.to_f
end
# ...
end
同樣的,我們可以建立wheel去測試它:
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
上面的做法,一樣有wheel初始化的成本,不過因為Gear將wheel抽離出來,而gear_inches實際上只需要一個包含diameter這個method的物件。
使用wheel來測試也有它的好處,想像如果wheel的diameter這個method改了,但Gear的gear_inches中的wheel.diameter卻沒有改。
class Wheel
attr_reader :rim, :tire
def initialize(rim, tire)
@rim = rim
@tire = tire
end
def width # <—— used to be 'diameter'
rim + (tire * 2)
end
# ...
end
這時候上面的測試就會出錯:
Gear
ERROR test_calculates_gear_inches
undefined method 'diameter'
目前就只有一個wheel的物件有包含diameter這個method,所以用wheel來測試也算合理,但如果以後有很多有diameter的物件出現,只用wheel測試便不合理。另一方面,在測試中需要建立wheel也造成了測試與wheel之間的相依性。
既然用wheel測試會花額外成本,又有相依性的問題。我們可以建一個所謂的diameterizable,也就是一個只有diameter method的物件如下:
# Create a player of the ‘Diameterizable’ role
class DiameterDouble
def diameter
10
end
end
class GearTest < MiniTest::Unit::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
diameterizable我們稱做double,它代表某一個角色做為測試之用。用double取代wheel可以避免初始化真實的物件,進而降低測試的成本與時間。但這裡存在一個問題,在剛剛wheel的更改diameter的名稱,但Gear的gear_inches卻沒有改,在這個測試反而通過了。
GearTest
PASS test_calculates_gear_inches
這個問題我們稱做living in dream,因為double沒辦法真實反應wheel的現況。不過這並不是測試的問題,而是人的問題,如果你改了wheel的diameter,理論上你已經改了diamterizable這個角色,所以你應該同時更改DiameterDouble的實作。
測試的另一個功能是提供文件,以wheel而言它其實是在扮演diameterizable這個角色,如果我們要將角色的資訊記錄起來 ,那麼應該要加了一個diameter有沒有存在的測試到wheel中如下:
class WheelTest < MiniTest::Unit::TestCase
def setup
@wheel = Wheel.new(26, 1.5)
end
def test_implements_the_diameterizable_interface
assert_respond_to(@wheel, :diameter)
end
def test_calculates_diameter
wheel = Wheel.new(26, 1.5)
assert_in_delta(29, wheel.diameter, 0.01)
end
end
不過這樣做實際上沒有什麼好處,反而還會帶來問題:
使用真實物件或是double來測試者是一種選擇,用真實物件會花費始使化的成本,但可以反應真實行為,使用double可以加快測試速度,但有可能會遇到living in dream的問題。在後面的內容會提出另一種解決living in dream的方法。
因為private method不會被物件本身以外的地方被存取,所以最理想的做法是不要去測試它。不過現實上還是有可能需要彈性的做法。
有很多理由說明應該不要測試private method:
避免針對private method寫測試,一個方式就是減少private method。如果一個物件包含了太多的private method,表示它可能包含太多的責任,這時候你應該要考量將它們提出到另一個物件中。但前提是提出之後產生的外部界面必須是穩定的,所以還是根據現實狀況要做選擇。
如果在設計的初期還不明瞭需求資訊時,可以將比較混亂的程式碼包在private,並用isolate的方式隔離外部界面,這會方便日後有多的資訊時可以修改。這樣的做法會造成private method極度不穩定,如果針對這些method寫測試,則測試也會時常更改。不過寫private method的測試也有一些好處,它會直接指出發生問題的地方,另外它會助於之後程式的refactor,重點在於這些private有機會在之後被refactor或是被提出到另一個物件,寫這樣的測試才有意義。
送出的訊息即是呼叫其它物件的method。
回到Gear的gear_inches的例子:
class Gear
# ...
def gear_inches
ratio * wheel.diameter
end
end
我們發現wheel.diameter只在這個method中用到,與其它的method並沒有關聯,所以應該要將Gear測試的重點放在gear_inches回傳的值是否如預期,而不是去注意wheel.diameter是否回傳正確的值,因為那是在wheel的測試中去測它。
但有時候送出的訊息會影響其它在物件中的method,這時候我們必須要測試它的正確性。下面是一個修改過的Gear範例:
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
當set_cog與set_chainring被呼叫時,它們必須在最後呼叫changed這個method去更改observer。我們的測試必須包含確定observer確實有被呼叫changed,這時候我們就需要使用mock,mock是用來測試訊息是否真的有送出。下面是測試的範例:
class GearTest < MiniTest::Unit::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
原則上我們不care observer.changed是否回傳正確的值,我們注重我們傳了什麼給observer.changed。
下面是一個使用duck typing的例子:
class Mechanic
def prepare_trip(trip)
trip.bicycles.each {|bicycle|
prepare_bicycle(bicycle)}
end
# ...
end
class TripCoordinator
def prepare_trip(trip)
buy_food(trip.customers)
end
# ...
end
class Driver
def prepare_trip(trip)
vehicle = trip.vehicle
gas_up(vehicle)
fill_water_tank(vehicle)
end
# ...
end
class Trip
attr_reader :bicycles, :customers, :vehicle
def prepare(preparers) preparers.each {|preparer|
preparer.prepare_trip(self)}
end
end
第一個要測試的是確保擁有preparable角色的class,必須實作prepare_trip,如果在每一個preparable的class中都要寫一次這種測試就顯得不太明智,還好ruby有個利用module共享測試的方法。我們先定義一個PreparerInterfaceTest:
module PreparerInterfaceTest
def test_implements_the_preparer_interface
assert_respond_to(@object, :prepare_trip)
end
end
這時候只要在preparable的class測試中include這個module就可以了:
class MechanicTest < MiniTest::Unit::TestCase
include PreparerInterfaceTest
def setup
@mechanic = @object = Mechanic.new
end
# other tests which rely on @mechanic
end
class TripCoordinatorTest < MiniTest::Unit::TestCase
include PreparerInterfaceTest
def setup
@trip_coordinator = @object = TripCoordinator.new
end
end
class DriverTest < MiniTest::Unit::TestCase
include PreparerInterfaceTest
def setup
@driver = @object = Driver.new
end
end
另一個要測試的東西就是要確保Trip可以正確的呼叫preparable的prepare_trip,這裡就會用到之前提到mock的技巧:
class TripTest < MiniTest::Unit::TestCase
def test_requests_trip_preparation
@preparer = MiniTest::Mock.new
@trip = Trip.new
@preparer.expect(:prepare_trip, nil, [@trip])
@trip.prepare([@preparer])
@preparer.verify
end
end
上面共享測試的方法可以用來解決之前遇到的living in dream的問題。首先我們要在wheel中確保它有實作width這個method:
module DiameterizableInterfaceTest
def test_implements_the_diameterizable_interface
assert_respond_to(@object, :width)
end
end
class WheelTest < MiniTest::Unit::TestCase
include DiameterizableInterfaceTest
def setup
@wheel = @object = Wheel.new(26, 1.5)
end
def test_calculates_diameter
# ...
end
end
接著在Gear的測試中,將diameterizable的double也做一樣的測試:
class DiameterDouble
def diameter
10
end
end
# Prove the test double honors the interface this # test expects.
class DiameterDoubleTest < MiniTest::Unit::TestCase
include DiameterizableInterfaceTest
def setup
@object = DiameterDouble.new
end
end
class GearTest < MiniTest::Unit::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
這時候,DiameterDoubleTest會出錯,因為DiameterDouble並沒有實作width。
DiameterDoubleTest
FAIL test_implements_the_diameterizable_interface
Expected #<DiameterDouble:...> (DiameterDouble) to respond to #width.
GearTest
PASS test_calculates_gear_inches
如果我們將DiameterDouble做了修正:
class DiameterDouble
def width
10
end
end
則會換GearTest出錯,因為gear_inches裡呼叫的還是wheel.diameter。
DiameterDoubleTest
PASS test_implements_the_diameterizable_interface
GearTest
ERROR test_calculates_gear_inches undefined method 'diameter'
for #<DiameterDouble:0x0000010090a7f8>
gear_test.rb:35:in 'gear_inches'
gear_test.rb:86:in 'test_calculates_gear_inches'
這樣的測試不但解決了living in dream的問題,同時可以明確的指出錯誤的地方。
下面是一個使用繼承的例子:
class Bicycle
attr_reader :size, :chain, :tire_size
def initialize(args={})
@size = args[:size]
@chain = args[:chain] || default_chain
@tire_size = args[:tire_size] || default_tire_size
post_initialize(args)
end
def spares
{ tire_size: tire_size,
chain: chain }.merge(local_spares)
end
def default_tire_size
raise NotImplementedError
end
# subclasses may override
def post_initialize(args)
nil
end
def local_spares
{}
end
def default_chain
'10-speed'
end
end
class RoadBike < Bicycle
attr_reader :tape_color
def post_initialize(args)
@tape_color = args[:tape_color]
end
def local_spares
{tape_color: tape_color}
end
def default_tire_size
'23'
end
end
首先我們要確保任何有繼承Bicycle的class都擁有對應的method,所以我們建立了一個BicycleInterfaceTest:
module BicycleInterfaceTest
def test_responds_to_default_tire_size
assert_respond_to(@object, :default_tire_size)
end
def test_responds_to_default_chain
assert_respond_to(@object, :default_chain)
end
def test_responds_to_chain
assert_respond_to(@object, :chain)
end
def test_responds_to_size
assert_respond_to(@object, :size)
end
def test_responds_to_tire_size
assert_respond_to(@object, :tire_size)
end
def test_responds_to_spares
assert_respond_to(@object, :spares)
end
end
在BicycleTest與RoadBicycle中就可以include這個module:
class BicycleTest < MiniTest::Unit::TestCase
include BicycleInterfaceTest
def setup
@bike = @object = Bicycle.new({tire_size: 0})
end
end
class RoadBikeTest < MiniTest::Unit::TestCase
include BicycleInterfaceTest
def setup
@bike = @object = RoadBike.new
end
end
同樣的,我們要確保RoadBicycle擁有Bicycle可以被覆寫的method,所以加了BicycleSubclassTest:
module BicycleSubclassTest
def test_responds_to_post_initialize
assert_respond_to(@object, :post_initialize)
end
def test_responds_to_local_spares
assert_respond_to(@object, :local_spares)
end
def test_responds_to_default_tire_size
assert_respond_to(@object, :default_tire_size)
end
end
class BicycleTest < MiniTest::Unit::TestCase
include BicycleInterfaceTest
def setup
@bike = @object = Bicycle.new({tire_size: 0})
end
end
class RoadBikeTest < MiniTest::Unit::TestCase
include BicycleInterfaceTest
include BicycleSubclassTest
def setup
@bike = @object = RoadBike.new
end
end
注意在BicycleTest中並沒有放BicycleSubclassTest,這是因為BicycleSubclassTest裡的method對父類別來說是沒有意義的。
接著我們要確保Bicycle本身的method必須有預設的行為,例如繼承Bicycle後的class如果沒有實作default_tire_size,就會出現NotImplementedError:
class BicycleTest < MiniTest::Unit::TestCase
include BicycleInterfaceTest
def setup
@bike = @object = Bicycle.new({tire_size: 0})
end
def test_forces_subclasses_to_implement_default_tire_size
assert_raises(NotImplementedError) {@bike.default_tire_size}
end
end
接著我們要測的是子類別自己實作的method,要注意的是這裡的測試不應該跟父類別有任何關係。
class RoadBikeTest < MiniTest::Unit::TestCase
include BicycleInterfaceTest
include BicycleSubclassTest
def setup
@bike = @object = RoadBike.new(tape_color: ‘red’)
end
def test_puts_tape_color_in_local_spares
assert_equal ‘red’, @bike.local_spares[:tape_color]
end
end
子類別的method測試完了,最後就是要測父類別中會被覆寫的行為,這時候我們需要建一個假的子類別StubbedBike繼承父類別並且用它來測試覆寫的行為:
class StubbedBike < Bicycle
def default_tire_size
0
end
def local_spares
{ saddle: 'painful' }
end
end
class BicycleTest < MiniTest::Unit::TestCase include BicycleInterfaceTest
def setup
@bike = @object = Bicycle.new({tire_size: 0})
@stubbed_bike = StubbedBike.new
end
def test_forces_subclasses_to_implement_default_tire_size
assert_raises(NotImplementedError) {
@bike.default_tire_size
}
end
def test_includes_local_spares_in_spares
assert_equal @stubbed_bike.spares,
{ tire_size: 0, chain: '10-speed', saddle: 'painful'}
end
end
同樣的,我們要確保StubbedBike符合Bicycle子類別應該要有的界面,所以可以在StubbedBikeTest中include BicycleSubclassTest。
class StubbedBikeTest < MiniTest::Unit::TestCase
include BicycleSubclassTest
def setup
@object = StubbedBike.new
end
end
好的測試必須與程式能有低的耦合,同時一段程式碼應該放在適當的地方而且只需要測一次就好。