Chapter 3: Test-Driven Rails

Making the Test Pass

end-to-end test
― Rails 5 Test Prescriptions (Rails 5.1, RSpec 3.7) ―

讓 RSpec 去跑上面的 end-to-end test 後,會得到第一個錯誤訊息:

NameError: undefined local variable or method `new_project_path'

會得到這個錯誤訊息是因為在 Chapter 2 建立的 Project 類別並不是標準的 ActiveRecord resources,也沒有對應的路徑,因此 RSpec 沒有辦法找到 new_project_path 路徑。

解決之道其一是使用 rails generate 指令如下:

rails g resource project name:string due_date:date

rails g resource task project:references title:string size:integer completed_at:datetime

因為已經使用 ActiveRecord 了,所以要移除原本在 project.rb 的 initialize 方法,保留其餘的方法如下圖:

Project Class
― Rails 5 Test Prescriptions ―

然後在 task.rb 修改如下圖:

Project Class
― Rails 5 Test Prescriptions ―

在終端機輸入

rails db:migrate

完成之後,除了本章節加入的 end-to-end test 外,上ㄧ章節所撰寫的 spec 應該就都可以順利通過了。

Days Are Action-Packed

雖然已經新增了 new_project_path 路徑,不過測試又爆出另一個錯誤訊息:

1) adding a project allows a user to create a project with tasks
     Failure/Error: visit new_project_path

     AbstractController::ActionNotFound:
     The action 'new' could not be found for ProjectsController

很明顯的,錯誤訊息表示 Project controller 還缺了ㄧ個 new 方法:

class ProjectsController < ApplicationController
  def new
    @project = Project.new
  end
end

再次跑 RSpec 後會觸發了新的錯誤訊息,因為 RSpec 會預期在 /app/views/projects 裡找到 new.html.erb。立馬補上該檔案後,得到以下 Capybara 錯誤訊息:

1) adding a project allows a user to create a project with tasks
     Failure/Error: fill_in "Name", with: "Project Runway"

     Capybara::ElementNotFound:
       Unable to find field "Name"

Capybara 在表格中會從 DOM ID、表格名稱、或是相關的標籤尋找符合測試要求的欄位。但是 new.html.erb 還是空白的狀態,所以要增加三個表格元件來回應錯誤訊息:(1)text_field for the name (2)multiline text area for the tasks (3)submit button

Project Class
― Rails 5 Test Prescriptions ―

注意到: 在倒數第四行中的 text_area_tag 將會用來對專案中的任務做些運算。 如果這裡使用了 form_for 所提供的 f.text_area :tasks , Rails 會嘗試讓 text_area 欄位中的值,變成 task 屬性的值。 但是 task 並不是 Project 類別的屬性,因此才需要 text_area_tag。

目前測試將送出表格,但是會以失敗收場。現階段的測試會在 project controller 裡,尋找被『送出表格』所喚醒的 create 方法。

Going with Workflow

在建立專案時,測試表格裡除了需要填寫名稱外,還需要檢視ㄧ系列的名單,並從中挑選任務。因此只有傳遞 params 的 create() 方法已經不能滿足設定的需求了,很明顯地我們需要撰寫ㄧ段 coding logic 來解決挑選任務這個『商業邏輯』。

這個需要撰寫的 coding logic,不管你將它放在哪裡,Rails 仍然會堅持有 controller 的存在,因此需要決定該如何(或者要不要),測試最後落在 controller 裡的邏輯。

我們先來探討商業邏輯這個部分,之後再回到 controller。

以此專案管理系統為例,當原有 ActiveRecord#create 中的 “pass the params hash” 已經不足以應付使用者輸入的商業邏輯時,新的商業邏輯可以放置在以下 3 個常見的位置:

  1. 新增的邏輯放在 Controller
    • 較適合簡易的邏輯。 缺點是會增加測試的難度、不好重構程式碼、以及當需要分享邏輯時,會比較麻煩。
  2. 新增的邏輯放在相關的 Model
    • 比放在 controller 更好,但是仍然不易於程式碼的重構,並且要熟悉 Ruby class method 的語意不是ㄧ件簡單的事。
  3. 另外建立不同的 class 來封裝邏輯和工作流程
    • 最易於測試及處理商業邏輯改變後所增加的難度。 缺點是經常最後留下許多小的類別物件。

Placing business logic outside Rails classes makes that logic easier to test and manage. ― Rails 5 Test Prescriptions ―

作者選擇第三選項:建立ㄧ個邏輯類別(logic class),並命名為 workflow class。 其他常見的邏輯類別名稱包括:action、service、context、use case、concern 和 factory。

Project Class
― Rails 5 Test Prescriptions ―

這是簡單明瞭的ㄧ個測試。 由於作者選擇不將『程式邏輯』放置在 controller 裡面,你甚至不需要撰寫華麗的程式碼。作者之所以會把此類別取名為 CreatesProject , 是因為作者不喜歡以名詞來幫 action class 命名。 其他可替代 CreatesProject 的名稱可以是: CreateProject、ProjectCreator 或是 ProjectFactory。

接著建立 CreatesProject 類別就可以通過測試了

Project Class
― Rails 5 Test Prescriptions ―

備註: 如果你得到以下錯誤訊息

NameError: expected file ...create_project.rb to define constant CreatesProject, but didn't creates_project.rb

就代表你需要把該檔案放在 app > models 資料夾內

注意到 build 方法只會創造 project object 但是不會進行儲存。

作者通常在建立 action object 的時候,會將 『initialization』、『execution』、和 『persisting the result』 分離出來。 這樣做的理由是在不需要資料庫的情形下,使用 workflow 類別來撰寫測試會加更容易及快速。

另一個作者巧思是在 initialize() 方法中帶入 Hash 型態的引數 (name: “”) ,來確保之後傳入的值是正確的。

下一步再把其餘『字串分析』的測試補上

Project Class
Project Class
― Rails 5 Test Prescriptions ―

測試裡的 specs 是循序漸進地在進步,因為每個 spec 都是根據前一個 spec 所無法完成的前提下而產生的。值得注意是有在 specs 中有使用到 has_attributes 的 matcher。 這個 matcher 允許你用一行的程式碼就可以明確指出相同物件所擁有的多種屬性。

expect(tasks.first).to have_attributes(title: "Start Things", size: 1)

注意:在測試多個任務的 spec 裡有使用 RSpec ”matches“ matcher 來取得 data structure 並一個接一個地進行比對。

expect(tasks).to match(
    [an_object_having_attributes(title: "Start Things", size: 3),

    an_object_having_attributes(title: "End Things", size: 2)])

而這個 “an_object_having_attributes” matcher 在測試中是同等於

expect(tasks[0]).to have_attributes(title: "Start Things", size: 3)
expect(tasks[1]).to have_attributes(title: "End Things", size: 2)

測試 “save” 的行為是很重要的,因為當所有的物件都是已儲存的狀態, Rails associations 有時候會運行得更好。我們知道 Rails 是用 ID 來追蹤物件之間的關聯,而只有當物件是已儲存的時候,Rails 才會給予物件 ID。

還有ㄧ個小技巧是可用 “save!” 方法來替代 save,因為當物件是 invalid 時,“save!” 會立刻丟出 exception 讓你知道測試有問題。 及早發現才能及早治療的意思!!

再次回到 CreatesProject 類別上:

Project Class
― Rails 5 Test Prescriptions ―

大致來說,新增的商業邏輯及相關測試就完成了。 不過在 creates_project_spec.rb 有許多重複的相同設定及類似編碼,在下一篇文章就會提到幾種不同的 RSpec 編碼風格來處理這樣的情形。