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)

In the case of this book, it’s sufficiently complex that I’ll start with the initial state and move to the happy path.

Here’s your spec of a project’s initial state:

# 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
    • 在第 1 行就使用 rails_helper 檔案。rails_helper file 會放置 Rails 相關的共同測試設定,以及 requires 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 expectation:是 expect(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有所回應。這兩個普通的 Ruby 方法 to() 和 not_to(),只接收ㄧ個 RSpec matcher 類型的引數。以上面程式碼為例子,be_truthy 是 RSpec 所定義的方法,並設定會回傳 BeTruthy matcher。
    • We could get the same behavior with:
       expect(project.done?).to(RSpec::BuiltIn::BeTruthy.new)
      
  3. The ExpectationTarget is now holding on to 2 objects: the object being matched (project.done?) and the matcher(be_truthy). When the spec is executed, RSpec calls the matches? method on the matcher, with the object being matched as an argument. If the expectation uses to, then the expectation passes if matches? method return true. If the expectation uses not_to, then it checks for a does_not_match? method in the matcher. If there is not such method, it falls back to passing if matches is 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 後的結果 – 這些結果有的是 “爆出錯誤” 或是 “輸出值的改變”。