Chapter 2: Test-Driven Development Basics

The First Date

預測專案的完成日期是以三個星期內能夠完成任務的數量來計算,因此系統必須判別哪些任務是三週內完成的?哪些任務不是? 這意味著在測試中將會面對到與日期相關的問題,例如:有些測試在某天會失敗,而有些測試會在某個時間點通過。 為了不要模糊重點,會從小單元的測試開始,盡量減少日期時間所造成的錯誤。

接下來的測試是讓 Task 實例變數本身,能夠檢視自己是否有在三週內完成。 如果任務是『未完成』或是『超過三星期』才完成,將不被列入計算 velocity 的一部分。

1    describe "velocity" do
2      let(:task) { Task.new(size: 3) }
3
4      it "does not count an incomplete task toward velocity" do
5        expect(task).not_to be_a_part_of_velocity
6        expect(task.points_toward_velocity).to eq(0)
7      end
8
9      it "counts a recently completed task toward velocity" do
10        task.mark_completed(1.day.ago)
11        expect(task).to be_a_part_of_velocity
12        expect(task.points_toward_velocity).to eq(3)
13     end
14
15     it "does not count a long-ago completed task toward velocity" do
16       task.mark_completed(6.months.ago)
17       expect(task).not_to be_a_part_of_velocity
18       expect(task.points_toward_velocity).to eq(0)
19     end
20   end

幾個在 Task 類別所做的改變將用於在這些 specs 中

首先,現有的任務完成機制已經改變了。在程式碼行數 16 及 10,mark_completed() 方法被修改成能接受一個代表任務完成日期的引數。注意這裡的引數是可選擇性的(optional argument),是因為不想動到先前已經撰寫好的測試。不過還是需要修改在 Project 測試中,原本對任務使用 completed 成為 completed_at。

已經在 Task 類別裡新增了兩個方法:

  1. part_of_velocity? (implied by the be_part_of_velocity matcher)
    • 根據先前設定的規定,如果該任務是在 3 週內完成的, part_of_velocity? 就會回傳 true。
  2. points_toward_velocity

在撰寫測試的風格上,不應該是針對某個 implementation 的細節,而是要考慮合適的方法名稱及如何測試方法行為。 樂觀的來說,如果是以行為來做測試,你在處理無法避免的需求改變時會做得更好。

相較於 part_of_velocity? 方法, points_toward_velocity 方法是更為棘手的。 如果該任務可以被納入任務完成率,points_toward_velocity 方法會依照任務的規模回傳一個整數值。反之,回傳值則會是零。 這就是以測試來設計類別介面的例子。其中的想法就是將所有的邏輯放在 Task 類別,更明確地說,目標是要讓專案不用詢問任務兩次 (ㄧ次是詢問任務狀態,另一次是詢問任務規模),就能獨立判別需要完成專案的時間。

“ There are few interesting design considerations in the specs themselves. Although the order in which you write the specs probably doesn’t make a long-term difference, the order in the file above is what you get if you try to write a spec that will break the existing code each time. By that I mean that I wrote the first test and passed it with very basic changes: a part_of_velocity? method that returned false and a points_toward_velocity method that returned 0. Since the third spec has the same outcome, it would pass without further code changes, requiring larger code changes when the remaining spec is added. However, the second spec, where the task is a part of velocity, requires you to add logic specifying that the task must be complete to count toward velocity. Then the third spec says it must have been completed recently. When you think about writing the next spec so that it breaks existing code, it is easier to move in small steps. ” ― Rails 5 Test Prescriptions ―

以測試的風格來說,在測試檔案的行數 10 和 16,有借用到 Rails helpers 來指出相對於現在的日期,例如:6.months.ago 不會列入任務完成率的計算,但是 1.day.ago 則會列入計算。 如果只是單純地以 yesterday 為引數傳進 mark_completed() 方法,日期會慢慢地往後推,遲早也是會超過 3 星期,終究測試也是會失敗。

另一個關於測試設計及日期的趣事:在 specs 裡,不管是六個月或是ㄧ天都離系統所預設的三星期非常遙遠。你或許會質疑為什麼不把測試日期,設定離界線較靠近呢?其實這種質疑聲反映了『以測試來設計』跟『以測試來驗證』的差別。 在嚴格的 TDD 定義下,我們應該要避免撰寫原本就期望會通過的測試,因為通常這樣測試並不會驅動我們去優化程式碼。

只有當我們合理懷疑在邊界條件下測試會失敗時,才會撰寫程式碼來測試。 例如:對日期或是時間來說,都蠻常發生在處理 SQL date ranges VS. Ruby date ranges,或是時區不同所衍生的問題。在處理這樣的問題時,新增邊界條件下的測試是為了嘗試找出錯誤,而不是要『以測試來驗證』為目標。

The resulting Task class looks like this:

class Task
  attr_accessor :size, :completed_at

  def initialize(options = {})
    mark_completed(options[:completed_at]) if options[:completed_at]
    @size = options[:size]
  end

  def mark_completed(date = Time.current)
    @completed_at = date
  end

  def complete?
    completed_at.present?
  end

  def part_of_velocity?
    return false unless complete?
    completed_at > 21.days.ago
  end

  def points_toward_velocity
    part_of_velocity? ?
    size : 0
  end
end

Using the Time Data

緊接著再來看 Project 測試,這裏稍微改變在 project_spec 的前置作業:

let(:project) { Project.new }
let(:newly_done) { Task.new(size: 3, completed_at: 1.day.ago) }
let(:old_done) { Task.new(size: 2, completed_at: 6.months.ago) }
let(:small_not_done) { Task.new(size: 1) }
let(:large_not_done) { Task.new(size: 4) }

before(:example) do
  project.tasks = [newly_done, old_done, small_not_done, large_not_done]
end

在此次的前置作業中,新增了一個已完成的任務,並且藉由『傳入的完成日期』來區分是否可以列入任務完成率的計算。 請注意新任務加入後的規模大小已經從之前的 7 變成 10 了。

基於修改後現在所擁有的資料,從簡單的計算就可以判斷出『預測的專案狀態』:

1       it "knows its velocity" do
2         expect(project.completed_velocity).to eq(3)
3       end
4
5       it "knows its rate" do
6         expect(project.current_rate).to eq(1.0 / 7)
7       end
8
9       it "knows its projected days remaining" do
10        expect(project.projected_days_remaining).to eq(35)
11      end
12
13      it "knows if it is not on schedule" do
14        project.due_date = 1.week.from_now 15 expect(project).not_to be_on_schedule
15      end
16
17      it "knows if it is on schedule" do
18        project.due_date = 6.months.from_now 20 expect(project).to be_on_schedule
19      end

目前在這些測試中,撰寫程式碼的風格上還是存在許多可以改善的地方。 雖然是對 Project 所做的測試,這些 specs 仍然隱約地存在著對 Task 類別的依附問題。當然這不是良好的測試,因為依附問題會使我們更難找出測試失敗的原因。在 Chapter 7,將會介紹如何使用 “Test Doubles as Mocks and Stubs” 來改善依附問題。

本書作者在測試中對使用數學採取不同的策略。 在行數 6 的 RSpec expectation 是讓 matcher 以數學除法 (1.0 / 7) 的方式來與 (project.current_rate) 做核對。而另一種不同的策略,在行數 10 的 RSpec expectation 則是直接將數學計算結果 (35) 與 (project.projected_days_remaining) 做核對測試。

第一種策略讓 matcher 以數學算式來表示會比較清楚易懂,因為它直接描述答案是怎麼來的。相較之下,第二種策略則是會讓人疑惑 matcher 中的 (35) 是從哪裡跑出來的。

然而第一種策略的缺點是它鼓勵直接從測試的程式碼,複製貼上在最後要執行的程式裡。通常最好是讓 implementation code 獨立於測試自己本身。另外,當與 floating-point 數字比較時,implementation 通常會不夠精準地使用 equality 來做比較。

“ Using the entire 1.0 / 7 expression in both places should make the spec and the code more likely to be equal; otherwise you can use the RSpec matcher “

expect(actual).to be_within(delta).of(expected).

― Rails 5 Test Prescriptions ―

這樣通過測驗的程式碼是有點 anticlimactic,我們將所有的條件式的邏輯放在 Task 類別中,目的是讓 Project 程式碼更加容易理解的。 這個好徵兆,代表著我們有合理地重構、優化程式碼。

def completed_velocity
  tasks.sum(&:points_toward_velocity)
end

def current_rate
  completed_velocity * 1.0 / 21
end

def projected_days_remaining
  remaining_size / current_rate
end

def on_schedule?
  (Time.zone.today + projected_days_remaining) <= due_date
end

除此之外,還需要在 Project 類別中新增 attr_accessor :due_date。

This passes the tests and moves you into the refactoring phase. I don’t see anything in the code that screams for a refactoring (although one reviewer did suggest turning the rate into a Ruby Rational instance). I’m considering extracting the (Time.zone.today + projected_days_remaining) logic to a method called projected_end_date, but you don’t need to do that at the moment.

找尋潛在危險得特例來確保測試的完整性是非常重要的,例如:專案內都是未完成的任務的情況。

You can put this test, along with the other initialization tests, in the original project_spec.rb file, inside the describe block, for initialization:

it "properly handles a blank project" do
  expect(project.completed_velocity).to eq(0)
  expect(project.current_rate).to eq(0)
  expect(project.projected_days_remaining).to be_nan
  expect(project).not_to be_on_schedule
end

在 it block 內的前三個 assertions 會順利通過測試; 最後一個 assertion 則會需要加入一些程式碼。

注意到 be_nan 使用到先提到過的 RSpec dynamic matcher 會對 nan? 檢查回傳值,如果是 true 就代表 此數字是 『Not A Number』。 你也需要確認當沒有任務時,projected_days_remaining 方法不會報錯。

You can use the same predicate in the code to make the on_schedule? assertion pass like this:

def on_schedule?
  return false if projected_days_remaining.nan?
  (Time.zone.today + projected_days_remaining) <= due_date
end

接著就再度回到重構程式碼的階段

首先會注意到有資料重複的情況: 在 Project#current_rate 和 Task#part_of_velocity? 都使用到了 21-day (決定任務是否要納入完成率的計算的專案設定)。不過作者認為 velocity length 感覺比較像是屬於 Project 的 static constant,而不是屬於任務的變數。 因此我們可以新增ㄧ個 Project 的類別方法 velocity_length_in_days() 來回傳 21-day。

def self.velocity_length_in_days
  21
end

作者認為與使用 constant 來代表 21-days,不如以類別方式來回傳此專案設定 因為這個設定在將來是有可能成為動態的形式。

current_rate() 方法在 Project 類別應該修改成:

def current_rate
  completed_velocity * 1.0 / Project.velocity_length_in_days
end

part_of_velocity? 方法在 Task 類別應該修改成:

def part_of_velocity?
  return false unless complete?
  completed_at > Project.velocity_length_in_days.days.ago 
end