Chapter 5: Testing Models

在 Rails 的框架中,在 model layer 包括『商業邏輯』以及『使用 ActiveRecord 存取資料庫的邏輯』。

通常來說,所有的 ActiveRecord object 都是 model layer 的ㄧ部分,但並不代表在 model layer 內的所有ㄧ切都是 ActiveRecord object。 例如當數據不需要儲存到資料庫中,單純的 PORO (Plain Old Ruby Object) 就可以取代 model 的功能了。

Model layer 可包含許多 service object 、 value object 或是能封裝邏輯並且使用 ActiveRecord object 來儲存的物件。

有關 service object 的解說可參考 為你自己學 Ruby on Rails

Persistence logic

The logic that interacts with database s/w and manipulates database data through the insert, update, delete, and select operations is called persistence logic. India Study Channel

A TDD Metaprocess

以下圖表說明當新的商業邏輯產生,需要建置新功能時,作者會參考以下指導原則來開發。 作者也提到了當新的邏輯越複雜,剛開始要程式碼實作時就知道的越少,也就會更依靠指導原則及採取更小的步驟來實作。

Project Class
― Rails 5 Test Prescriptions ―

在 TDD,通常最好的開始就是在不喚醒任何邏輯的情況下,以ㄧ個能描述系統初始狀態的測試來開始。 這樣的手法對於是要以測試來產出ㄧ個新的類別會特別的好用: 第一個測試只會是 『set up the class』 和 『verifies the initial state of instance variables』。但如果只是用在已存在的類別,這樣的作法也許就不是那麼好用了。

接著,需要判斷什麼樣的情境下會驅動新的邏輯。 有時候可能只有一種主要的情境:例如:“計算單一使用者的購物總金額”

有時候會有許多情境:

例如: “計算購物車內的總金額,加上在不同區域稅金額度(會需要考慮當地政府的法規)”

“ Ideally these tests are small—if they need a lot of setup, you probably should be testing a smaller unit of code. As you saw in the first few chapters, sometimes you’ll pass the first test by putting in a solution that is deliberately specific to the test, like a one-line method that only returns the exact value that makes the test pass. ”― Rails 5 Test Prescriptions ―

這些測試類型的目標是盡快通過驗證,暫時不要考慮到之後實作的細節,而是尋找程式碼重構的入口。 當完成測試中全部的主要情境,就要思考如何讓現有的程式碼造成測試失敗。 有時候雖然完成了測試,會好奇如果將 nil 值為引數帶入測試中會發生什麼情況?,修正後再撰寫ㄧ個正確的測試。

而當你再也想到不到可讓測試失敗的方法,你大概就完成了該功能的測試,便可以繼續測試下個功能了。 你也可以試著跑玩完所有的系統測試,確認你沒有不小心將哪個測試弄壞,然後再看看有無重構程式碼的機會。

程式碼的重構會變得越來越重要,因為測試中會有許多特例及錯誤的情境,導致程式碼變得複雜。 因此未來要優化程式碼,如何處理高複雜度的測試就會顯得更重要。

小撇步:建議可以將處理特例的測試放到後面再撰寫,因為如果先完成了正常的測試案例,就可以將沒問題的程式碼拿來檢查『之後要撰寫的特例程式碼』。

Refactoring Models

在 TDD 過程中,許多撰寫程式碼的構想都是在重構的步驟產生出來的,通常這些突如其來的想法會藏在優化程式碼的過程中:例如:簡化程式碼的結構。

程式碼的重構步驟是讓你思考如何優化程式碼。 省略此步驟將會累積成為更大的問題,等你意識到需要解決的時候,除了變得更困難外,也許已經來不及了。

“ Refactoring is where a lot of design happens in TDD, and it’s easiest to do in small steps. Skip it at your peril. ” ― Rails 5 Test Prescriptions ―

ㄧ般來說,重構程式碼可從以下 3 個地方來觀察:

(1) complexity to break up
(2) duplication to combine
(3) abstractions waiting to be born

(1) Break Up Complexity

複雜的程式碼經常可以從過長的方法或是程式碼來看出。 在 TDD 最初的階段,假如是以先通過測試為目標,通常會看到ㄧ行載滿許多方法的程式碼。 這就是可以進入重構的入口,你可以將過長的程式碼,解析再分離為各自的方法,以重新命名名稱的方式,也提高程式碼的閱讀性。

大部分的布林值 (Booleans)、區域變數 (local variables) 和 行內備註(inline comments) 都是可以從複雜的程式碼中,再細分出來獨立成為實體變數或是方法。

Compound Boolean Expression

其實許多人在理解 compound expression 並沒有想像的容易。 如果能將複合的詞句以重新命名方式,來表示程式碼的意圖會更容易讓人理解,例如:valid_name? or has_purchased_before?

“Any compound Boolean logic expression goes in its own method.” ― Rails 5 Test Prescriptions ―

Local Variables

『區域變數』則是相較下更容易分解成與變數相同名稱的方法。 在 Ruby 的世界裡, 如果要將『變數』轉換成『不帶引數的方法』,原本代表變數的程式碼是不需要修改的。 過多的『區域變數』對程式碼重構而言會是一種累贅。 如果你能將使用『區域變數』的數量降到最低,也許你會驚訝撰寫程式碼變得如此地靈活。

Inline Comments

對『方法有太長的問題』而言,有時候可藉著單一行註解來描述下一個部分做什麼,就能分離過長的方法。 經由解析註解來重構程式碼,可能從原本有 25 行程式碼的方法減少到只有 5 行的程式碼。

“ This nearly always is better extracted to a separate method with a name based on the comment’s contents. ” ― Rails 5 Test Prescriptions ―

(2) Combine Duplication

你需要注意到三種程式碼『重複』的形式:
(2-1) 事實的重複: duplication of fact
(2-2) 邏輯的重複: duplication of logic
(2-3) 結構的重複: duplication of structure

(2-1) Duplication of Fact

『事實的重複』通常很容易就可以判別跟修正。 這裡指的是重複使用了 “ magin number ” 在許多程式碼區塊中,例如: 『變數的轉換』或是『變數的最大值』。 在上ㄧ個章節中,就使用了 21 天,這個 “magin number” 來計算完成任務的速率。

以下的另ㄧ個例子:是使用有效值不多的狀態變數。

validates :size, numericality: {less_than_or_equal_to: 5}

def possible_sizes
  (1 .. 5)
end

要解決 “duplication of fact” 的問題: 可以設定該值為『常數』或是以『方法』回傳常數值

MAX_POINT_COUNT = 5

validates :size, numericality: {less_than: MAX_POINT_COUNT}

def possible_sizes
  (1 .. MAX_POINT_COUNT)
end

或是也可以這樣做:

VALID_POINT_RANGE = 1 .. 5

validates :size, inclusion: {in: VALID_POINT_RANGE}

“ The key metric is how many places in the code would need to change if the underlying facts change, with the ideal number being 1. That said, at some point the extra character count for a constant is ridiculous and Java-like in the worst way. For string and symbol constants, if the constant value is effectively identical to the symbol (as in ACTIVE_STATUS = :active), I’ll often leave the duplication. I’m not saying I recommend that; I’m just saying I do it. ” ― Rails 5 Test Prescriptions ―

書中作者經常會以實體方法回傳值,而不是直接設定為 Ruby constant:

def max_point_count
  5
end

選擇以上這樣做法的好處是除了提高程式碼的閱讀性之外,可以經由物件的實體方法查詢得到該最大值。另外,如果常數之後有改變也比較好修改。

(2-2) Duplication of Logic

『邏輯的重複』有點於類似於『事實的重複』,但要留意的部分改為過長的程式碼結構,而不是尋找某一個特別的值 。以任務管理範例來說, 『邏輯重複』也許會是『任務的完成與否』或是以 “任務規模” 換算 “完成任務所需時間” 的『簡易計算』。

通常『邏輯重複』會包括以 “compound Boolean statements (if…else) “ 來篩選值。 在以下的例子中, Boolean test 就在不同方法中被運用了 2 次:

class User
  def maximum_posts
    if status == :trusted then 10 else 5 end
    end

  def urls_in_replies
    if status == :trusted then 3 else 0 end
  end
end

其中一個解決該問題的方式,是將重複的邏輯獨立出來為自己的方法,然後在原本的位置呼叫該方法。 以上面的例子來看,就可以將 “status == :trusted” 邏輯重複部份,分離出來為 def trusted? 方法。

另外,要記住不是每一個邏輯的名稱拼寫相同就ㄧ定是有邏輯重複的問題。 事實上,有可能在初期階段的邏輯很相似,但後來衍生為完全不同行為的物件。 例如: 每個 RESTful controller 一開始都是相同的樣板,會得到 一樣的 7 個 RESTful actions.

(3) Duplication of Structure

『程式碼結構的重複』通常是指缺乏 abstraction『擷取程式碼的相同之處』,在 Ruby 中的意思是能夠轉移部分程式碼到新的類別中。

『程式碼結構』發生連續重複的徵兆,可以從實體屬性是如何被呼叫,來判別是否有存在該問題。 如果當同類別的屬性傳入到許多方法中,發現運用這些實體屬性都是『永遠』在ㄧ起的,就代表存在著該問題。 換句話說,當使用ㄧ系列共同變數通常象徵著需要ㄧ個新類別,來附有這些合適的實體屬性。

另一個常見的徵兆是當ㄧ連串的方法名稱都有著相同的『字首』或是『字尾』,例如:logger_init, logger_print, and logger_read。 這往往代表著你需要一個新的類別與這些字首/字尾來對應。

在 ActiveRecord 裡,找到合適屬性的ㄧ個附加作用是 Value Objects 的產生。 Value object 是能代表你的部分資料而永遠不變的實體,例如: start_date & end_date 都經常拿來被ㄧ起使用,並且能輕易的聯合在一起組成為 DateRange class。

你是否也經常撰寫類似以下的程式碼呢?

Project Class
Project Class
― Rails 5 Test Prescriptions ―

如果 User 的相關測試已經存在以上情況,新增 Name 類別依然會讓原有那些測試通過驗證。

當在 refactor 階段時, 測試的目標是 『標物的功能性』而不是實作上的細節,因此是不需要改變測試的內容。 但如果你選擇轉移部分程式碼到新的類別時,基於期望新類別會經常地被分享運用,也許會有需要新增或是修改原有的測試內容。

當選擇將相關的屬性獨立出來為新的類別時,如同先前介紹的 Name 範例,你會發現有ㄧ個分離於 User 的地方,有助於未來需要增加複雜度時,實作上將會更容易。

除此之外,較小的類別也比較好測試,因為 Name class 獨立出來之後就不在需要在資料庫上存取,或是依靠其他程式碼。 在沒有與程式碼的存依關係下,撰寫測試會比較容易跟快速:

it "generates sortable names" do
  name = Name.new("Noel", "Rappin")
  expect(name.sort_name).to eq("Rappin, Noel")
end

撰寫新的 Name class 是可以快速完成及運行的,你會發現越簡單就寫出來的測試,會有越多的測試將需要撰寫。

另外,會需要注意重複的 if statements 或是其他在有條件下會需要啟動同一個值的程式碼。 舉例來說,情況像是經常需要檢查 nil 值,或是 status 變數都是常見的例子。

例如: task tracker 內就可能會有許多方法做類似以下的事情:

if status == :completed
  calculate_completed_time
else
  calculate_incompleted_time
end

無可避免地,每ㄧ個程式偶爾都會用到 if statement。 但是如果有需要持續性地確認 『object’s state』來決定下ㄧ步的動作,就可以考慮新增類別來處理。 舉例來說,先前上面的程式碼片段就暗示著應該要有 CompleteTaskIncompleteTask 的存在。

另外,也許任務的完成度只會影響到類別的部分功能,因此會新增類似 CompleteTaskCalculatorIncompleteTaskCalculator 的類別。

一但選擇將功能分離成不同的類別裡,物件導向的程式就應該使用 『message passing』和 『polymorphism』來轉換以類別為基礎:

Polymorphism

Polymorphism is one of the fundamental features of object oriented programming, but what exactly does it mean? At its core, in Ruby, it means being able to send the same message to different objects and get different results.

有趣的解釋
“Teddy(sender,等一下準備送出信息的物件)走在路上看到前方有兩位名人,分別是「林志玲」與「阿美姐」(等一下準備接收訊息的兩個物件),於是大喊一聲「美女請留步(訊息)」。理論上Teddy期待只有「林志玲」會「回頭」(訊息接收者的行為),沒想到「阿美姐」也回頭了...XD。所以說,一個訊息的解釋是由接收者來決定的,而不是送出者。如果一個系統具有這樣的特性,那麼我們就說這個系統具備多型的行為。”

-出自於 搞笑談軟工

Once you’ve separated functionality into separate classes, an object-oriented program is supposed to switch based on class, using message passing and polymorphism:

def calculator
  if complete?
    CompleteTaskCalculator.new(self)
  else
    IncompleteTaskCalculator.new(self)
  end
end

def calculate
  calculator.calculate_time
end
" In the most recent example there is still an if statement about the logic between complete and incomplete tasks, but only one. Any further difference between complete and incomplete tasks is handled in the difference between the two calculator classes. If you’re testing for completion in many places, separating functionality into classes can be more clear."

― Rails 5 Test Prescriptions ―

A Note on Assertions per Test

撰寫測試有兩種非常不同的風格:

(1) 所有的 『assertions』 跟 『setup』 都是屬於相同測試的ㄧ部分
Project Class
― Rails 5 Test Prescriptions ―
(2) 將每個『assertion』放在分別的測試,『共同的setup』放在 setup block 裡面
Project Class
― Rails 5 Test Prescriptions ―

Trade-Off

『one-assertion-per-test style』能夠清楚地看出哪個驗證發生錯誤,因為每個測試都是獨立分別出來驗證的。 相比之下,『all the assertions are in a single test style』則限制於驗證的順序,當第一個 assertion 失敗就會無法得知後面的 assertion 是否有通過測試。

例如:當 『expect(task).to be_complete』驗證失敗時, RSpec 就不會繼續檢查『expect(task).to be_blocked』。不過也因為每個測試都是獨立驗證的情況下,也會更難看出測試之間的關係。

總體來說,『one-assertion-per-test style』會有 2 個主要的缺點:
  1. 測試速度慢:因為每個 assertion 都需要跑過一次共同的 setup
  2. 測試閱讀性低: 因為每個 assertio 都是獨立出來,特別是分別的空間又拉大的情形下
作者的做法
  1. 先利用『one-assertion-per-test style』作為 TDD 的第一個測試,因為這樣可以強迫自己撰寫測試的步伐可以小ㄧ點,也能獲得較清楚的回饋當錯誤發生時。
  2. 接著等自己對撰寫的程式碼有信心時,就改為『all the assertions are in a single test style』就可提高測試的速度。
"Another compromise is the use of compound RSpec matchers or the has_attributes matcher to create a single assertion out of what might otherwise be multiple assertions."

― Rails 5 Test Prescriptions ―

最近的 RSpec 版本則是開始提供 :aggregate_failure 選項,能強迫 RSpec 必需要跑完全部的 assertions 再報告結果。

這樣就可以有速度快及閱讀性高的好處,同時不被驗證的順序所限制住:

Project Class
― Rails 5 Test Prescriptions ―

你可以選擇將 :aggregate_failure 選項放在 it 或是 describe method 中。 如果你選擇放在 describe method 裡,就代表在 describe 區塊內的所有 spec 都將使用 aggregate_failure 選項。 你甚至可以在 configuration 註明要使用 aggregate_failure 為每ㄧ個 spec 的預設選項。 不過在整合測試時,aggregate_failure 卻可能會造成問題。

“Expectations that cover different branches of the application logic should be handled in spearate specs” ― Rails 5 Test Prescriptions ―

不管你選擇哪種方式處理你的 specs, 概括軟體邏輯中不同部分的 expectations 應該要分別被不同的 spec 來負責驗證。 另外要避開在 specs 中改變 local variables 就只是因為要在同ㄧ個測試中驗證不同邏輯。 這代表你應該盡量讓這些 specs 分離出來。

Project Class
― Rails 5 Test Prescriptions ―

Testing What Rails Gives You

Rails 有提供內建的 associations 跟 validations 功能,但究竟要如何有效地測試這些功能在你的軟體中。

答案其實可從最基本的原則得出:“測試是在驗證功能,而不是實作的細節” (We’re testing functionality and not implementation)。 雖然作者並不會特別去測試某一個 association 或是 validation 的存在,但是有時候會撰寫測試來驗證上線後的功能如何表現。

“ For associations, this means showing the association in use. For validations, it means testing the overall logic of what makes an instance valid. ” ― Rails 5 Test Prescriptions ―

shoulda-matchers gem 來說, 這個 gem 就特別定義了 matchers 來驗證 associations 跟 validations 的存在:

Project Class
― Rails 5 Test Prescriptions ―

To Be Continued…