Chapter 2: Test-Driven Development Basics

Running the Test

What Really Happens – Internally?

When you run spec with no argument, RSpec loads every file in the spec directory. The following things happen:

  1. Each file in the spec directory is loaded.

  2. Each RSpec file typically requires the rails helper.rb file.

  3. By default the rails helper.rb file set up transactional fixtures.

  4. Each top-level call to RSpec.describe creates an internal RSpec object called an example group.

  5. Each top-level example group runs.

Running an example group involves running each example that it contains, and that involves a few steps:
  1. Run all before(:example) setup blocks.
  2. Run the example, which is the block argument to it.
  3. Run all after(:example) teardown blocks.
  4. Roll back or delete the fixtures as described earlier.
  5. After all examples in the file have run, run any after(:all) blocks.

The diagram below show the flow

The process of running an example in RSpec
Rspec Internal Execution
Piture From Rails 5 Test Prescriptions

Making the Test Pass

  • The purist way

    Do the simplest thing that could possible work.

  • The practical way

    Write the code you know you need to eventually write, effectively skipping steps that seem too small to be valuable.

  • The teaching way

    This method is somewhere between the other two and lets me best explain how and why test-driven development works without getting bogged down in details or skipping too many steps.

之後作者會進階式的講解:如何從 Rspec 給的錯誤訊息撰寫出正確的編碼,以及構思下一個需要的測試為何。

以下是第一個需要通過的測試:

# 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
過程紀錄:
  1. 如果直接去跑 rspec 的話,會得到 uninitialzied constant:Project 錯誤訊息,但新增 Project class 就可以解決。
  2. 但是再跑 rspect,又會得到 undefined method ‘done?’ 錯誤訊息。既然找不到 done? 方法,就在 Project class 建立 done? 方法。
  3. 這個 done? 方法必須回傳 true ,否則就會得到 nil 值。完成後就成功地通過第一個 rspec 測試,也代表著進入到程式碼重構 (refactoring) 步驟。
class Project
  def done?
    true
  end
end

The Second Test

我們剛剛讓 done? 方法永遠只會回傳 true,但如果專案裡的還任務沒有完成,就應該要回傳 false 才合理。 因此有了以下測試:

it "knows that a project with an incomplete task is not done" do
  project = Project.new
  task = Task.new
  project.tasks << task
  expect(project.done?).to be_falsy
end
過程紀錄:
  1. 跑完 rspec 的話,會得第一個錯誤訊息 Task constant is missing ,新增 Task class 就可以解決。
  2. 接著第二個錯誤訊息會爆出 project.tasks並不存在,解決方法就是讓 Project 類別裡加入 tasks 屬性 attr_accessor :tasks
  3. 另外讓每一個 Project 的實例物件都預設能夠接受 task 的陣列,便可以在 done? 方法內,以 empty? 查詢個別 project 裡的陣列是否還有 task 存在。
class Project
  attr_accessor :tasks

  def initialize
    @tasks = []
  end

  def done?
    tasks.empty?
  end
end

在上一個章節,我們學到了優良的 TDD 會有以下過程:

  1. 建立測試
  2. 確認在預定設定下,測試會失敗
  3. 撰寫最簡單就能夠通過測試的程式碼
  4. 通過測試後,重構並優化程式碼

目前為止,我們成功了完成前三項工作。現在就來到了最後一項:重構並優化程式碼

Let and Expectations

Let statement

使用 RSpec 的 let 方法可以將重複的變數做一個整合。也就是說在相同的 describe 區塊裡,可以讓每一個 spec 使用 let 所定義的變數,而不需要『 定義變數為實例變數 』或是使用 『 before(:example) 』 來做前置作業。

在 describe 的區塊裡,每一個 let 方法都可接受ㄧ個 symbol 引數 和 block。

let(:project) { Project.new }

當第一次呼叫 let 方法內的 symbol 時,會喚醒後面的 block 和保留住執行後的結果。之後對相同 symbol 的呼叫就不會再喚醒 block,而是直接回傳結果。而當進入每一個新 spec 時,let 方法後面接的 block 會再次被喚醒,symbol 引數也將重新被定義一次。

require "rails_helper"

RSpec.describe Project do
  let(:project) { Project.new }
  let(:task) { Task.new }

  it "considers a project with no tasks to be done" do
    expect(project).to be_done
  end
  it "knows that a project with an incomplete task is not done" do
    project.tasks << task
    expect(project).not_to be_done
  end
end

在以上程式碼範例:使用了兩次 let 方法,分別是用來描述 projecttask。 請注意到即使是在第一個 spec 中沒有用到 task 也是沒有關係的,因為 let 方法後面接的區塊只有在該變數被使用時,才會被喚醒。實際上,let 方法是一種語法糖衣,其原形如下:

def me
  @me ||= User.new(name: "Noel")
end

這裡要記住的是 let 後面接的區塊,除非是被喚醒,不然是不會執行的。這樣的 lazy load 特質對測試來說是好事,因為就不會浪費時間在建立不必要的物件。

另外 RSpect 還提供 let! 去執行後面接的 block,不論在 spec 內是否有使用到變數。

Dynamic matchers

在 RSpec 無法識別該 matcher 的情況下,RSpec 會利用名稱修飾(name-mangling)的技巧,建立所謂的『 implicit matcher 』。

Predicate method

舉例來說,任何 matcher 長得像 be_whatever 或是 be_a_whatever,RSpec 會假設這兩種 matchers 都會擁有ㄧ個相關 matcher ,叫做 whatever?。 這個 whatever? 就稱為 predicate method。 如果 matcher 是被 to 呼叫,那通過測試的條件就是從 predicate method 得到回傳值 true。反之,如果 matcher 是被 not_to 呼叫,那通過測試的條件會是從 predicate method 得到回傳值是 false

還記得在先前的範例中,程式碼中有 expect(project.done?).to be_truthy。 其實這個 done? 方法就是predicate method,所以作者可以將這個 RSpec expectation 重新改寫成較為口語化的編碼: expect(project).to be_done

另外一點就是 RSpec 允許使用 andor 將 不同的 matcher 連接一起。

expect(actual).to include("a").and match(/.*3.*/)
expect(actual).to eq(3).or eq(5)

你也可以將 matchers 當作引數傳給其他的 matchers,又或者是創作 matchers 以 match 來處理全部的資料結構。

1  expect(actual[0]).to eq(5)
2  expect(actual[1]).to eq(7)
3  expect(actual).to match([an_object_eq_to(5), an_object_eq_to(7)])

第三行的 RSpec expectation,在 actual 陣列中的每ㄧ個元素都會拿來與 an_object_eq_to(5) 跟 an_object_eq_to(7),也可以簡化成 eq(5) and eq(7) 做配對比較。 然而大部分 RSpec 內建的 matchers 都有相似的語法,可讓測試編碼更口語化。 舉例來說:『 expect(actual).to all(be_truthy) 』, 這個 all matcher 會拿 matcher 引數(這裡指的是 be_truthy ),然後再與 actual 陣列中的每個元素做配對。