Chapter 2: Test-Driven Development Basics

本章節開始要實作ㄧ個任務管理系統,將能參照先前任務的完成率,來預估專案應有的完成日期。

Infrastructure

建立名為 gatherer 的 rails 專案專案

Requirement

此系統將以先前任務的完成率,分析專案中已完成或是未完成的任務,預測未來任務完成日期。

換句話說,在系統新增專案及輸入相關任務(有些會是未完成任務)後,系統將預測專案完成日期及判斷是否專案有照排程進行。

Installing RSpec

當安裝完 ‘rspec-rails’ gem 後,會需要在終端機輸入

rails generate rspec:install

之後 generator 會建立以下資料夾及檔案:

  • The .rspec file: where RSpec run options go.
  • The spec directory: 這邊是放你的 specs, 需要測試的 spec 都會在這裡。
  • The spec_helper.rb: 包含一般預設 RSpec 的設定。
  • The rails_helper.rb files: 此檔案會 requires ‘spec_helper’,及載入 Rails 框架跟相關環境設定。

當載入 Rails 專案時,’rspec-rails’ gem 會在幕後默默地做以下兩件事情:

  1. 會加入 Rake 檔案讓原本 Rails 使用的預設測試(Minitest)轉為 RSpec,也會定義ㄧ些 Rake 任務,例如:spec::models(P.14)。

  2. 將 RSpec 設定為 Rails 框架中測試的選項,之後使用 Rails generator 就會產生 RSpec 相關測試設定,例如:rspec:model

Where to Start?

在 TDD 循環中,ㄧ個好的起點可以是:

  1. 物件或是方法的初始化狀態 (initial state)

  2. 單ㄧ有代表性、零錯誤版本的實例 (happy path)

以書中的任務管理系統為例,作者選擇以『初始化狀態 (initial state)』為起點,再轉移到『代表性、零錯誤版本的實例 (happy path)』。

以下就是專案初始化狀態的 spec:

# project_spec.rb

1  require "rails_helper"
2
3  RSpec.describe Project do
4    it "considers a project with no tasks to be done" do
5      project = Project.new
6      expect(project.done?).to be_truthy
7    end
8
9  end

project_spec.rb 檔使用了 4 個 RSpect 和 Rails 的基本特質:

  1. It requires rails_helper
    • 注意到第ㄧ行中的 rails_helper 檔案會涵蓋所有 Rails 測試相關的共同設定,接著進而載入 spec_helper 檔案。 spec_helper 則含有非 Rails 相關的 RSpec 設定。
  2. It defines a test suite with RSpec.describe
    • describe() 方法會定義ㄧ系列可以分享共同設定的 specs 。此方法的第ㄧ個引數可以是 class name 或是 string。 這第一個引數應當要說明該測試會涵蓋之範圍。
  3. It creates an RSpec example with it
    • 真正的 spec 其實是在 it() 方法中定義的,這個 it() 方法可以接受 3 個引數:
      1. ㄧ個非強制性的引數,描述這個 spec 的內容
      2. an optional amount of metadata
      3. 一個 block(spec 的主要內容)
  4. It specifies a particular state with expect
    • RSpec expectationexpect(actual_value).to(matcher)
    • A matcher: 是 RSpect object,會接受一個值並且判斷這個值是否有符合測試中預想的結果。(e.q., be_truthy)

我們來檢視 RSpect 實際上在這個 expectation 做了什麼?

expect(project.done?).to be_truthy
  1. 首先來看看 expect(project.done?)。 RSpect 定義 expect() 方法,會接受任何 object 型態的引數,再會回傳ㄧ個特別的 RSpec proxy object,叫做ExpectationTarget

  2. 這個ExpectationTarget會抓住先前傳進 expect() 內的物件,並且對 to and not_to有所回應。這兩個 to() 和 not_to() 是普通的 Ruby 方法 ,只接收ㄧ個 RSpec matcher 類型的引數。 以上面程式碼為例子,be_truthy 是 RSpec 所定義的方法,並設定會回傳 BeTruthy matcher。

     # 其實上面 RSpec expectation 的原形就是這樣
     expect(project.done?).to(RSpec::BuiltIn::BeTruthy.new)
    
  3. 此時這個 ExpectationTarget 已抓住了2個物件:

    • (1) 先前傳進 expect() 內的物件
    • (2) RSpec matcher (be_truthy)

而當該 spec 被執行時,RSpec 會在 RSpec matcher (這裏指的是 be_truthy) 身上呼叫 『matches?』方法,並以待確認的物件 (project.done?) 當引數。

如果這個 expectation 使用 to() 方法就意味著,當『matches?』方法回傳 true , 該 expectation 會成功通過測試。反之,使用 not_to() 方法時,就會在 RSpec matcher 先尋找『does_not_matche?』方法,如果該方法不存在,expectation 會讓測試通過,當『matches?』方法回傳 false。

RSpec Predefined Matchers

以下列出比較常見的 matchers, 你也可以點選這裡來檢視完整的名單。

  • expect(array).to all(matcher)
  • expect(actual).to be > expected # (also works with <, >=, <=, and ==)
  • expect(actual).to be_a(type)
  • expect(actual).to be_truthy
  • expect(actual).to be_falsy
  • expect(actual).to be_nil
  • expect(actual).to be_between(min, max)
  • expect(actual).to be_within(delta).of(expected)
  • expect { block }.to change(receiver, message, &block)
  • expect(actual).to contain_exactly(expected)
  • expect(range).to cover(actual_value)
  • expect(actual).to eq(expected)
  • expect(actual).to exist
  • expect(actual).to have_attributes(key/value pairs)
  • expect(actual).to include(*expected)
  • expect(actual).to match(regex)
  • expect { block }.to output(value).to_stdout # also to_stderr
  • expect { block }.to raise_error(exception)
  • expect(actual).to satisfy { block }

以上 matchers 大概都可以從字義猜出它的作用,但以下幾個 matchers 會需要多一點說明:

  • all matcher

    只有當 expect() 引數內所以有陣列元素都通過指定的 matcher 才會通過測試,。

    • Use the all matcher to specify that a collection’s objects all pass an expected matcher. This works on any enumerable object
expect(array).to all(matcher) 
expect([1,2,3]).to all(be_truthy)
expect([1, 3, 5]).to all( be_odd )
expect([1, 3, 5]).to all( be_an(Integer) )
expect([1, 3, 5]).to all( be < 10 )
  • change matcher

    當 expect() 內的 block 改變時,receiver.message 也會隨之改變,這樣才會通過測試。換句話說,change matcher 內的 object.attribute 會隨著 do_something 改變。

    • The change matcher is used to specify that a block of code changes some mutable state.
expect { block }.to change(receiver, message, &block)
expect { do_something }.to change { object.attribute }
expect { Counter.increment }.to change { Counter.count }.from(0).to(1)

  • contain_exactly matcher

    在忽略排序的情況下,如果『預計的陣列』跟『實際上的陣列』都含有相同的元素才會通過測試。

    • The contain_exactly matcher provides a way to test arrays against each other in a way that disregards differences in the ordering between the actual and expected array.
expect(actual).to contain_exactly(expected)
expect([1, 2, 3]).to contain_exactly(2, 3, 1) # pass
expect([:a, :c, :b]).to contain_exactly(:a, :c ) # fail
  • satisfy matcher

    如果評估 block 為 true 才會通過測試。

    • The satisfy matcher is extremely flexible and can handle almost anything you want to specify. It passes if the block you provide returns true:
      expect(10).to satisfy { |v| v % 5 == 0 }
      expect(7).not_to satisfy { |v| v % 5 == 0 }
      expect(actual).to satisfy { block }
      

注意: 有別於其他的 matchers 會指出特定物件的狀態改變,這些接受 block 引數、使用 output() 或是 raise_error() 方法的 matchers 會明確地指出執行 block 後的結果 – 這些結果有的是 “爆出錯誤” 或是 “輸出值的改變”。