測試熱潮現在傳播到了 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 文件
            require 'machine'
                      

          接下來,需要運行測試。始終通過輸入 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 類
          class Machine
                      end

          保存文件,然后運行測試。毫無疑問,清單 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。該方法具有一個使用引號括起來的字符串參數和一個代碼塊。字符串應該描述測試需求。最后,doend 之間的代碼塊包含測試用例的代碼。

          可以看到,測試進度劃分得很細。這些微小的步驟產生的收益卻很大。它們使我能夠改進測試密度,提供時間供我思考期望的行為以及實現行為所需的 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。測試用例通過觸發 checkoutaccept_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





          組合代碼

          現在,我具有一個可簡單運行的狀態機。無論從哪方面來說,它都不是一個完美的程序。它還具有下面這些問題:

          • 狀態散列實際上不具備任何功能。我應該根據狀態對事件及其轉換進行驗證,或者將所有狀態集中起來。后續需求很可能會要求這樣做。
          • 某個既定事件只能存在于一個狀態中。這種限制并不合理。例如,submitcancel 事件可能需要處于多個狀態。
          • 代碼并不具備明顯的面向對象特征。為使配置保持簡單,我將大量數據置入散列中。后續的迭代會進一步驅動設計,使其朝面向對象設計方向發展。

          但是,您還可以看到,這個狀態機已經能夠滿足一些需求了。我還具備一個描述系統行為的文檔,這是進行一系列測試的好起點。每個測試用例都支持系統的一個基本需求。事實上,通過運行 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 閱讀(322) 評論(0)  編輯  收藏 所屬分類: RORagile
          主站蜘蛛池模板: 蛟河市| 保定市| 海南省| 高雄县| 开化县| 南城县| 沽源县| 郎溪县| 白朗县| 济宁市| 佛坪县| 江西省| 凌海市| 神木县| 辉南县| 潞西市| 新昌县| 和硕县| 诸暨市| 兴山县| 哈密市| 扎鲁特旗| 泸西县| 忻州市| 徐汇区| 博白县| 德令哈市| 六安市| 双峰县| 克拉玛依市| 绥阳县| 芒康县| 吴旗县| 太仆寺旗| 闸北区| 铁岭市| 彭阳县| 香港| 布尔津县| 长治市| 江华|