測試熱潮現在傳播到了 Ruby 編程社區,并且愈演愈熱。在過去一年里,測試領域中最為矚目的創新應屬 RSpec 的引入和快速發展,這是一種行為驅動測試工具。通過本文了解 RSpec 如何改變人們思考測試的方式。
在過去十年中,軟件開發人員對測試的熱情日漸低迷。同一時期出現的動態語言并沒有提供編譯程序來捕捉最基本的錯誤,這使得測試變得更加重要。隨著測試社區的成長,開發人員開始注意到,除了捕獲 bug 等最基本的優點外,測試還具有以下優勢:
- 測試能夠改進您的設計。進行測試的每個目標對象必須具備至少兩個客戶機:生產代碼和測試用例。這些客戶機強制您對代碼進行解耦。測試還鼓勵開發人員使用更小、更簡單的方法。
- 測試減少了不必要的代碼。在編寫測試用例時,您養成了很好的測試習慣,即只編寫運行測試用例所需的最少代碼。您抵制住了對功能進行編碼的誘惑,因為您目前還不需要它。
- 推動了測試優先開發。您編寫的每個測試用例會確定一個小問題。使用代碼解決這個問題非常有用并且可以推動開發。當我進行測試驅動開發時,時間過得飛快。
- 測試提供了更多的自主權。在使用測試用例捕獲可能的錯誤時,您會發現自己非常愿意對代碼進行改進。
測試驅動的開發和 RSpec
有關測試的優點無需贅述,我將向您介紹一個簡單的使用 RSpec 的測試驅動開發示例。RSpec 工具是一個 Ruby 軟件包,可以用它構建有關您的軟件的規范。該規范實際上是一個描述系統行為的測試。使用 RSpec 的開發流程如下:
- 編寫一個測試。該測試描述系統中某個較小元素的行為。
- 運行測試。由于尚沒有為系統中的相應部分構建代碼,測試失敗。這一重要步驟將測試您的測試用例,檢驗測試用例是否在應當失敗的時候失敗。
- 編寫足夠的代碼,使測試通過。
- 運行測試,檢驗測試是否成功。
實質上,RSpec 開發人員所做的工作就是將失敗的測試用例調試為成功的測試用例。這是一個主動的過程。本文中,我將介紹 RSpec 的基本用法。
首先,假設您已安裝了 Ruby 和 gems。您還需要安裝 RSpec。輸入下面的內容:
gem install rspec
使用示例
接下來,我將逐步構建一個狀態機。我將遵循 TDD 規則。首先編寫自己的測試用例,并且直到測試用例需要時才編寫代碼。Rake 的創建者 Jim Weirich 認為這有助于角色扮演。在編寫實際的生產代碼時,您希望充當一回 jerk 開發人員的角色,只完成最少量的工作來使測試通過。在編寫測試時,您則扮演測試人員的角色,試圖為開發人員提供一些有益的幫助。
以下的示例展示了如何構建一個狀態機。如果您以前從未接觸過狀態機,請查閱 參考資料。狀態機具有多種狀態。每種狀態支持可以轉換狀態機狀態的事件。測試驅動開發入門的關鍵就是從零入手,盡量少地使用假設條件。針對測試進行程序設計。
使用清單 1 的內容創建名為 machine_spec.rb 的文件。該文件就是您的規范。您還不了解 machine.rb 文件的作用,目前先創建一個空文件。
清單 1. 最初的 machine_spec.rb 文件
接下來,需要運行測試。始終通過輸入 spec machine_spec.rb
運行測試。清單 2 展示了預料之中的測試失敗:
清單 2. 運行空的規范
~/rspec batate$ spec machine_spec.rb
/opt/local/lib/ruby/site_ruby/1.8/rubygems/custom_require.rb:27:in `gem_original_require':
no such file to load -- machine (LoadError)
from /opt/local/lib/ruby/site_ruby/1.8/rubygems/custom_require.rb:27:in `require'
from ./state_machine_spec.rb:1
from ...
|
在測試驅動開發中,您需要進行增量開發,因此在進行下一次開發前,需要先解決此次測試出現的問題。現在,我將扮演 jerk 開發人員的角色,即只完成滿足應用程序運行所需的最少工作量。我將創建一個名為 machine.rb 的空文件,使測試通過。我現在可以以逸待勞,測試通過而我幾乎沒做任何事情。
繼續角色扮演。我現在扮演一個煩躁的測試人員,促使 jerk 開發人員做些實際的工作。我將編碼以下規范,需要使用 Machine
類,如清單 3 所示:
清單 3. 初始規范
require 'machine'
describe Machine do
before :each do
@machine = Machine
end
end
|
該規范描述了目前尚不存在的 Machine
類。describe
方法提供了 RSpec 描述,您將傳入測試類的名稱和包含實際規范的代碼塊。通常,測試用例需要執行一定數量的設置工作。在 RSpec 中,將由 before
方法完成這些設置工作。您向 before
方法傳遞一個可選的標志和一個代碼塊。代碼塊中包含設置工作。標志確定 RSpec 執行代碼塊的頻率。默認的標志為 :each
,表示 RSpec 將在每次測試之前調用 set up 代碼塊。您也可以指定 :all
,表示 RSpec 在執行所有測試之前只調用一次 before
代碼塊。您應該始終使用 :each
,使各個測試彼此獨立。
輸入 spec
運行測試,如清單 4 所示:
清單 4. 存在性測試失敗
~/rspec batate$ spec machine_spec.rb
./machine_spec.rb:3: uninitialized constant Machine (NameError)
|
現在,煩躁的測試人員要促使 jerk 開發人員做點什么了 — jerk 開發人員現在需要創建某個類。對我來說,就是修復測試出現的錯誤。在 machine.rb
中,我輸入最少量的代碼,如清單 5 所示:
清單 5. 創建初始 Machine 類
保存文件,然后運行測試。毫無疑問,清單 6 顯示的測試報告沒有出現錯誤:
清單 6. 測試 Machine 是否存在
~/rspec batate$ spec machine_spec.rb
Finished in 5.0e-06 seconds
0 examples, 0 failures
|
編寫行為
現在,我可以開始實現更多的行為。我知道,所有狀態機必須在某些初始狀態下啟動。目前我還不是很清楚如何設計這個行為,因此我先編寫一個非常簡單的測試,首先假設 state
方法會返回 :initial
標志。我對 machine_spec.rb
進行修改并運行測試,如清單 7 所示:
清單 7. 實現初始狀態并運行測試
require 'machine'
describe Machine do
before :each do
@machine = Machine.new
end
it "should initially have a state of :initial" do
@machine.state.should == :initial
end
end
~/rspec batate$ spec machine_spec.rb
F
1)
NoMethodError in 'Machine should initially have a state of :initial'
undefined method `state' for #<Machine:0x10c7f8c>
./machine_spec.rb:9:
Finished in 0.005577 seconds
1 example, 1 failure
|
注意這條規則: it "should initially have a state of :initial" do @machine.state.should == :initial end
。首先注意到這條規則讀起來像是一個英文句子。刪除標點,將得到 it should initially have a state of initial
。然后會注意到這條規則并不像是典型的面向對象代碼。它確實不是。您現在有一個方法,稱為 it
。該方法具有一個使用引號括起來的字符串參數和一個代碼塊。字符串應該描述測試需求。最后,do
和 end
之間的代碼塊包含測試用例的代碼。
可以看到,測試進度劃分得很細。這些微小的步驟產生的收益卻很大。它們使我能夠改進測試密度,提供時間供我思考期望的行為以及實現行為所需的 API。這些步驟還能使我在開發期間跟蹤代碼覆蓋情況,從而構建更加豐富的規范。
這種風格的測試具有雙重作用:測試實現并在測試的同時構建需求設計文檔。稍后,我將通過測試用例構建一個需求列表。
我使用最簡單的方式修復了測試,返回 :initial
,如清單 8 所示:
清單 8. 指定初始狀態
class Machine
def state
:initial
end
end
|
當查看實現時,您可能會放聲大笑或感覺受到了愚弄。對于測試驅動開發,您必須稍微改變一下思考方式。您的目標并不是編寫最終的生產代碼,至少現在不是。您的目標是使測試通過。當掌握以這種方式工作時,您可能會發現新的實現,并且編寫的代碼要遠遠少于采用 TDD 時編寫的代碼。
下一步是運行代碼,查看它是否通過測試:
清單 9. 運行初始狀態測試
~/rspec batate$ spec machine_spec.rb
.
Finished in 0.005364 seconds
1 example, 0 failures
|
花些時間思考一下這個通過測試的迭代。如果查看代碼的話,您可能會覺得氣餒。因為并沒有取得什么進展。如果查看整個迭代,將看到更多內容:您捕獲了一個重要需求并編寫測試用例實現需求。作為一名程序員,我的第一個行為測試幫助我明確了開發過程。因為實現細節隨著測試的進行越來越清晰。
現在,我可以實現一個更健壯的狀態實現。具體來講,我需要處理狀態機的多個狀態。我需要創建一個新的規則獲取有效狀態列表。像以前一樣,我將運行測試并查看是否通過。
清單 10. 實現有效狀態規范
it "should remember a list of valid states" do
@machine.states = [:shopping, :checking_out]
@machine.states.should = [:shopping, :checking_out]
end
run test(note: failing first verifies test)
~/rspec batate$ spec machine_spec.rb
.F
1)
NoMethodError in 'Machine should remember a list of valid states'
undefined method `states=' for #<Machine:0x10c7154>
./machine_spec.rb:13:
Finished in 0.005923 seconds
2 examples, 1 failure
|
在清單 10 中,出現了一個 RSpec 形式的斷言。該斷言從 should
方法開始,然后添加了一些比較關系。should
方法對應用程序進行某種觀察。工作中的應用程序應該以某種方式運行。should
方法很好地捕獲了這種需求。在本例中,我的狀態機應該記憶兩種不同的狀態。
現在,應該添加一個實例變量來實際記憶狀態。像以往一樣,我在修改代碼后運行測試用例,并觀察測試是否成功。
清單 11. 創建一個屬性以記憶狀態
class Machine
attr_accessor :states
def state
:initial
end
end
~/rspec batate$ spec machine_spec.rb
..
Finished in 0.00606 seconds
2 examples, 0 failures
|
驅動重構
此時,我并不想決定將 :initial
狀態稱為狀態機的第一個狀態。相反,我更希望第一個狀態是狀態數組中的第一個元素。我對狀態機的理解在不斷演變。這種現象并不少見。測試驅動開發經常迫使我重新考慮之前的假設。由于我已經通過測試用例捕獲了早期需求,我可以輕松地對代碼進行重構。在本例中,重構就是對代碼進行調整,使其更好地工作。
修改第一個測試,使其如清單 12 所示,并運行測試:
清單 12. 初始狀態應該為指定的第一個狀態
it "should initially have a state of the first state" do
@machine.states = [:shopping, :checking_out]
@machine.state.should == :shopping
end
~/rspec batate$ spec machine_spec.rb
F.
1)
'Machine should initially have a state of the first state' FAILED
expected :shopping, got :initial (using ==)
./machine_spec.rb:10:
Finished in 0.005846 seconds
2 examples, 1 failure
|
可以這樣說,測試用例起到作用了,因為它運行失敗,因此我現在需要修改代碼以使其工作。顯而易見,我的任務就是使測試通過。我喜歡這種測試目的,因為我的測試用例正在驅動我進行設計。我將把初始狀態傳遞給 new
方法。我將對實現稍作修改,以符合修改后的規范,如清單 13 所示。
清單 13. 指定初始狀態
start to fix it
class Machine
attr_accessor :states
attr_reader :state
def initialize(states)
@states = states
@state = @states[0]
end
end
~/rspec batate$ spec machine_spec.rb
1)
ArgumentError in 'Machine should initially have a state of the first state'
wrong number of arguments (0 for 1)
./machine_spec.rb:5:in `initialize'
./machine_spec.rb:5:in `new'
./machine_spec.rb:5:
2)
ArgumentError in 'Machine should remember a list of valid states'
wrong number of arguments (0 for 1)
./machine_spec.rb:5:in `initialize'
./machine_spec.rb:5:in `new'
./machine_spec.rb:5:
Finished in 0.006391 seconds
2 examples, 2 failures
|
現在,測試出現了一些錯誤。我找到了實現中的一些 bug。測試用例不再使用正確的接口,因為我沒有把初始狀態傳遞給狀態機。可以看到,測試用例已經起到了保護作用。我進行了較大的更改,測試就發現了 bug。我們需要對測試進行重構以匹配新的接口,將初始狀態列表傳遞給 new
方法。在這里我并沒有重復初始化代碼,而是將其放置在 before
方法中,如清單 14 所示:
清單 14. 在 “before” 中初始化狀態機
require 'machine'
describe Machine do
before :each do
@machine = Machine.new([:shopping, :checking_out])
end
it "should initially have a state of the first state" do
@machine.state.should == :shopping
end
it "should remember a list of valid states" do
@machine.states.should == [:shopping, :checking_out]
end
end
~/rspec batate$ spec machine_spec.rb
..
Finished in 0.005542 seconds
2 examples, 0 failures
|
狀態機開始逐漸成型。代碼仍然有一些問題,但是正在向良好的方向演化。我將開始對狀態機進行一些轉換。這些轉換將促使代碼實際記憶當前狀態。
測試用例促使我全面地思考 API 的設計。我需要知道如何表示事件和轉換。首先,我將使用一個散列表表示轉換,而沒有使用成熟的面向對象實現。隨后,測試需求可能會要求我修改假設條件,但是目前,我仍然保持這種簡單性。清單 15 顯示了修改后的代碼:
清單 15. 添加事件和轉換
remember events... change before conditions
require 'machine'
describe Machine do
before :each do
@machine = Machine.new([:shopping, :checking_out])
@machine.events = {:checkout =>
{:from => :shopping, :to => :checking_out}}
end
it "should initially have a state of the first state" do
@machine.state.should == :shopping
end
it "should remember a list of valid states" do
@machine.states.should == [:shopping, :checking_out]
end
it "should remember a list of events with transitions" do
@machine.events.should == {:checkout =>
{:from => :shopping, :to => :checking_out}}
end
end
~/rspec batate$ spec machine_spec.rb
FFF
1)
NoMethodError in 'Machine should initially have a state of the first state'
undefined method `events=' for #<Machine:0x10c6f38>
./machine_spec.rb:6:
2)
NoMethodError in 'Machine should remember a list of valid states'
undefined method `events=' for #z7lt;Machine:0x10c5afc>
./machine_spec.rb:6:
3)
NoMethodError in 'Machine should remember a list of events with transitions'
undefined method `events=' for #<Machine:0x10c4a58>
./machine_spec.rb:6:
Finished in 0.006597 seconds
3 examples, 3 failures
|
由于新的測試代碼位于 before
中,將我的三個測試分解開來。盡管如此,清單 16 中展示的測試非常容易修復。我將添加另一個訪問程序:
清單 16. 記憶事件
class Machine
attr_accessor :states, :events
attr_reader :state
def initialize(states)
@states = states
@state = @states[0]
end
end
~/rspec batate$ spec machine_spec.rb
...
Finished in 0.00652 seconds
3 examples, 0 failures
test
|
測試全部通過。我得到了一個能正常運行的狀態機。接下來的幾個測試將使它更加完善。
接近真實的應用程序
目前為止,我所做的不過是觸發了一次狀態轉換,但是我已經做好了所有基礎工作。我得到了一組需求。我還構建了一組測試。我的代碼可以為狀態機提供使用的數據。此時,管理單個狀態機轉換僅表示一次簡單的轉換,因此我將添加如清單 17 所示的測試:
清單 17. 構建狀態機的狀態轉換
it "should transition to :checking_out upon #trigger(:checkout) event " do
@machine.trigger(:checkout)
@machine.state.should == :checking_out
end
~/rspec batate$ spec machine_spec.rb
...F
1)
NoMethodError in 'Machine should transition to :checking_out upon
#trigger(:checkout) event '
undefined method `trigger' for #<Machine:0x10c4d00>
./machine_spec.rb:24:
Finished in 0.006153 seconds
4 examples, 1 failure
|
我需要抵制快速構建大量功能的誘惑。我應該只編寫少量代碼,只要使測試通過即可。清單 18 展示的迭代將表示 API 和需求。這就足夠了:
清單 18. 定義 trigger 方法
def trigger(event)
@state = :checking_out
end
~/rspec batate$ spec machine_spec.rb
....
Finished in 0.005959 seconds
4 examples, 0 failures
|
這里出現了一個有趣的邊注。在編寫代碼時,我兩次都弄錯了這個簡單的方法。第一次我返回了 :checkout
;第二次我將狀態設置為 :checkout
而不是 :checking_out
。在測試中使用較小的步驟可以為我節省大量時間,因為測試用例為我捕獲的這些錯誤在將來的開發中很難捕獲到。本文的最后一個步驟是實際執行一次狀態機轉換。在第一個示例中,我并不關心實際的機器狀態是什么樣子的。我僅僅是根據事件進行盲目轉換,而不考慮狀態。
兩節點的狀態機無法執行這個操作,我需要在第三個節點中構建。我沒有使用已有的 before
方法,只是在新狀態中添加另外的狀態。我將在測試用例中進行兩次轉換,以確保狀態機能夠正確地執行轉換,如清單 19 所示:
清單 19. 實現第一次轉換
it "should transition to :success upon #trigger(:accept_card)" do
@machine.events = {
:checkout => {:from => :shopping, :to => :checking_out},
:accept_card => {:from => :checking_out, :to => :success}
}
@machine.trigger(:checkout)
@machine.state.should == :checking_out
@machine.trigger(:accept_card)
@machine.state.should == :success
end
~/rspec batate$ spec machine_spec.rb
....F
1)
'Machine should transition to :success upon #trigger(:accept_card)' FAILED
expected :success, got :checking_out (using ==)
./machine_spec.rb:37:
Finished in 0.007564 seconds
5 examples, 1 failure
|
這個測試將使用 :checkout
和 :accept_card
事件建立新的狀態機。在處理簽出時,我選擇使用兩個事件而不是一個,這樣可以防止發生雙命令。簽出代碼可以確保狀態機在簽出之前處于 shopping
狀態。第一次簽出首先將狀態機從 shopping
轉換為 checking_out
。測試用例通過觸發 checkout
和 accept_card
事件實現兩個轉換,并在調用事件之后檢驗事件狀態是否正確。與預期一樣,測試用例失敗 — 我并沒有編寫處理多個轉換的觸發器方法。代碼修正包含一行非常重要的代碼。清單 20 展示了狀態機的核心:
清單 20. 狀態機的核心
def trigger(event)
@state = events[event][:to]
end
~/rspec batate$ spec machine_spec.rb
.....
Finished in 0.006511 seconds
5 examples, 0 failures
|
測試可以運行。這些粗糙的代碼第一次演變為真正可以稱之為狀態機的東西。但是這還遠遠不夠。目前,狀態機缺乏嚴密性。不管狀態如何,狀態機都會觸發事件。例如,當處于 shopping
狀態時,觸發 :accept_card
并不會轉換為 :success
狀態。您只能夠從 :checking_out
狀態觸發 :accept_card
。在編程術語中,trigger
方法的范圍應針對事件。我將編寫一個測試來解決問題,然后修復 bug。我將編寫一個負測試(negative test),即斷言一個不應該出現的行為,如清單 21 所示:
清單 21: 負測試
it "should not transition from :shopping to :success upon :accept_card" do
@machine.events = {
:checkout => {:from => :shopping, :to => :checking_out},
:accept_card => {:from => :checking_out, :to => :success}
}
@machine.trigger(:accept_card)
@machine.state.should_not == :success
end
rspec batate$ spec machine_spec.rb
.....F
1)
'Machine should not transition from :shopping to :success upon :accept_card' FAILED
expected not == :success, got :success
./machine_spec.rb:47:
Finished in 0.006582 seconds
6 examples, 1 failure
|
現在可以再次運行測試,其中一個測試如預期一樣運行失敗。修復代碼同樣只有一行,如清單 22 所示:
清單 22. 修復 trigger 中的范圍問題
def trigger(event)
@state = events[event][:to] if state == events[event][:from]
end
rspec batate$ spec machine_spec.rb
......
Finished in 0.006873 seconds
6 examples, 0 failures
|
組合代碼
現在,我具有一個可簡單運行的狀態機。無論從哪方面來說,它都不是一個完美的程序。它還具有下面這些問題:
- 狀態散列實際上不具備任何功能。我應該根據狀態對事件及其轉換進行驗證,或者將所有狀態集中起來。后續需求很可能會要求這樣做。
- 某個既定事件只能存在于一個狀態中。這種限制并不合理。例如,
submit
和 cancel
事件可能需要處于多個狀態。
- 代碼并不具備明顯的面向對象特征。為使配置保持簡單,我將大量數據置入散列中。后續的迭代會進一步驅動設計,使其朝面向對象設計方向發展。
但是,您還可以看到,這個狀態機已經能夠滿足一些需求了。我還具備一個描述系統行為的文檔,這是進行一系列測試的好起點。每個測試用例都支持系統的一個基本需求。事實上,通過運行 spec machine_spec.rb --format specdoc
,您可以查看由系統規范組成的基本報告,如清單 23 所示:
清單 23. 查看規范
spec machine_spec.rb --format specdoc
Machine
- should initially have a state of the first state
- should remember a list of valid states
- should remember a list of events with transitions
- should transition to :checking_out upon #trigger(:checkout) event
- should transition to :success upon #trigger(:accept_card)
- should not transition from :shopping to :success upon :accept_card
Finished in 0.006868 seconds
|
測試驅動方法并不適合所有人,但是越來越多的人開始使用這種技術,使用它構建具有靈活性和適應性的高質量代碼,并且根據測試從頭構建代碼。當然,您也可以通過其他框架(如 test_unit)獲得相同的優點。RSpec 還提供了優秀的實現方法。這種新測試框架的一大亮點就是代碼的表示。新手尤其可以從這種行為驅動的測試方法中受益。請嘗試使用該框架并告訴我您的感受。
posted on 2007-10-23 19:01
lzj520 閱讀(321)
評論(0) 編輯 收藏 所屬分類:
ROR 、
agile