Chapter 2: Test-Driven Development Basics

Back on Test

接著再繼續上一篇未完成的測試,需要讓任務本身能夠被標注『是否已經完成』。

require "rails_helper"

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

  it "does not have a new task as complete" do
   expect(task).not_to be_complete
  end

  it "allows us to complete a task" do
    task.mark_completed
    expect(task).to be_complete
  end
end

此測試目前都是處於在 API 的層級,並且沒有要求代表或是取代完成任務的基礎機制。 這意味著只要 API 仍然是正常運作中,你可以改變 implementation 而不會搞砸了測試。 過度地依附在 implementation 的細節會造成在測試環境下會容易出錯,所以『行為』才是應該要被描述的,而不是特定的 implementation。

Prescription 4: When possible, write your tests to describe your code’s behavior, not its implementation. ― Noel Rappin ―

要通過這個測試可在 Task 類別加入以下幾個實例方法:

class Task
  def initialize
    @completed = false
  end

  def mark_completed
    @completed = true
  end

  def complete?
    @completed
  end
end

再新增ㄧ個測試,確保專案有判斷狀態的能力

it "marks a project done if its tasks are done" do
  project.tasks << task
  task.mark_completed
  expect(project).to be_done
end

在 done? 方法加上新的邏輯就會通過測試了

class Project
  attr_accessor :tasks

  def initialize
    @tasks = []
  end

  def done?
   tasks.all?(&:complete?)
  end
end

Adding Some Math

接著我們需要讓系統計算專案內還有多少未完成的任務?、完成率是多少?,並以這些結果來預測任務完成的日期。

接下來的測試就是計算未完成的任務有多少,要寫測試之前,可先構思這個測試需要什麼? 一般來說典型的測試結構有三大部分:

  1. Given (What data does the test need?)
    • 這個測試會需要什麼資料? 會需要一個專案、至少一個完成跟未完成的任務。
  2. When (What action is taking place?)
    • 什麼動作會在這個測試產生? 計算未完成任務的行為。
  3. Then (What behavior do I need to specify)
    • 什麼行為是需要具體說明的? 工作計算結果

” I also like to think about what could make the happy-path test fail; that’s more helpful once you get the happy-path test in place and then start looking at it critically.“ ― Noel Rappin ―

回答完以上三個問題之後,便可以開始下一個測試了。

1     describe "estimates" do
2       let(:project) { Project.new }
3       let(:done) { Task.new(size: 2, completed: true) }
4       let(:small_not_done) { Task.new(size: 1) }
5       let(:large_not_done) { Task.new(size: 4) }
6
7       before(:example) do
8         project.tasks = [done, small_not_done, large_not_done]
9       end
10
11      it "can calculate total size" do
12        expect(project.total_size).to eq(7)
13      end
14      it "can calculate remaining size" do
15        expect(project.remaining_size).to eq(5)
16      end
17    end

由於此次測試的前置作業與先前的 describe 區塊不同,因此另外新增 describe 區塊來描述 estimates 該做什麼。 在 describe 區塊中的兩個 it block, 皆使用由 let statment 以及 before block 組合的前置作業。對 RSpec 而言,在跑每個 spec 之前,都會優先執行 before(:each) 或是 before(:example) 區塊中的程式碼。

呼應上面介紹的三大典型測試結構,通常 specs 會遵循常見的 RSpec 模式:

  • Given data (測試資料) 會轉變成為一系列的 let statement
  • When action (測試動作) 會轉變成為 before 區塊內的程式碼
  • Then (測試各別條件) 會轉變成為一系列的 it statement

Prescription 6: Choose your test data and test-variable names to make it easy to diagnose failures when they happen. Meaningful names and data that doesn’t overlap are helpful. ― Noel Rappin ―

這次的測試會在第3行: Task.new(size: 2, completed: true) 爆出錯誤,因為 Task 類別並不是 Rails 的 ActiveRecord,所以預設的引數不會是 hash 型態。 找出問題後,就手動改寫成以下程式碼吧!

class Task
  attr_accessor :size, :completed

  def initialize(options = {})
    @completed = options[:completed]
    @size = options[:size]
  end

  def mark_completed
    @completed = true
  end

  def complete?
    @completed
  end
end

接著在15行,因為 expect(project.remaining_size).to eq(5) 又爆出錯誤 所以需要新增 remaining_size() 方法給 Project 類別

class Project
  attr_accessor :tasks

  def initialize
    @tasks = []
  end

  def done?
    tasks.all?(&:complete?)
  end

  def total_size
    tasks.sum(&:size)
  end

  def remaining_size
    tasks.reject(&:complete?).sum(&:size)
  end
end

通過測試後,又再次回到 TDD Cycle 的最後一個步驟: 重構程式碼

仔細檢查 Project class 後,可以發現有兩個方法都使用了未完成任務的名單:

  1. done? 方法: 詢問『未完成的任務名單』是否空白,如果空白的話就應當回傳 true
  2. remining_size 方法: 計算『未完成的任務名單』的長度

因此可以新增 incomplete_tasks() 方法,再從這裡來簡化

class Project
  attr_accessor :tasks

  def initialize
    @tasks = []
  end

  def incomplete_tasks
    tasks.reject(&:complete?)
  end

  def done?
    incomplete_tasks.empty?
  end

  def total_size
    tasks.sum(&:size)
  end

  def remaining_size
    incomplete_tasks.sum(&:size)
  end
end

雖然程式碼重構之後,提高了閱讀性,不過程式碼卻沒有精簡多少。但之後如果對任務完成的定義有改變,就只需改變在 incomplete_tasks() 方法內做修改即可。