🎯 什麼情境該想到我

當你「要加的功能該放在某個類別上,但那個類別是會把整個世界都吸進測試用具的『黑洞』類別」的時候。

⚙️ 怎麼用(步驟)

書先講一般原則:修改一個類別的最佳途徑,就是在測試用具中建立它的實例,為你想要進行的修改編寫相應的測試,然後作出修改來滿足該測試。然而有時候為了將一個類別納入測試需要花的工夫太大了(p.302)。作者舉的實例:某團隊接手的遺留系統裡,那些領域相關的類別幾乎直接依賴了系統內的其他所有類別;更糟的是這些類別居然還統統被綁進了一個持久化框架。把裡面的一個類別納入測試框架仍然是可行的,但如果時間都花在跟那些領域類別糾纏上面,做正事的時間就被佔用掉了。在那種情況下,為了獲得一些必要的分離,就用上了本節的技術(p.302)。

書中的例子是一個音樂合成工具:需要判斷一段音樂模式能否放進一條音軌的某個序列(找出序列中的「死時間」)。理想情況下該方法應該放在序列類別上,但序列類別正是那種黑洞類別;而事件類別的依賴情況並不比序列類別好,都會給建置帶來麻煩。關鍵轉折是:要計算一段模式能否放進一個序列,其實只需要每個事件的持續時間——於是可以先寫一個「基於整數來進行計算」的自由函式(不屬於任何類別,操作的是基於基本型別的序列表示),為它寫測試;有了它之後,再在原類別上加一個非常簡單的方法,把實質性的工作全部委託給那個自由函式(p.302–303)。

書中列的步驟(p.304):

  1. 編寫一個自由函式來實現你想要對目標類別做的事情。同時建立一個中間表示,以便你的自由函式進行處理。
  2. 往目標類別上添加一個函式來建構這一中間表示,並將實際任務轉發給上一步建立的那個自由函式。

★★ 在 Go 裡:它仍然適用,但要知道它是「刻意的技術債」

★ 書中的動機:★★ 一個方法的參數是一個「建不出來的重型別」
  → ★ 於是:★★ 加一個新方法,★ 它收「基本型別」而非那個重型別
  → ★ 原方法變成:★★ 把重型別拆成基本型別,再呼叫新方法
// ★ 問題:★★ 測試建不出 *Session(★ 它需要連線、需要認證)
func (s *Service) Process(sess *Session, o Order) error { … }
 
// ★ 樸素化參數:★★ 抽出一個只收基本型別的核心
func processCore(userID string, tier Tier, o Order) error { … }   // ★ 可測
 
func (s *Service) Process(sess *Session, o Order) error {
    return processCore(sess.UserID(), sess.Tier(), o)              // ★ 轉接
}

★★ 這一招在 Go 裡有一個特別的價值:★ 它產出的 processCore 是一個純函式
→ ★★ 而純函式在 Go 裡是「可以用表格測試窮舉」的意思(見 工具-Go表格測試與子測試)。

★★ 但它是 Feathers 明確標示為「不理想」的手法,理由在 Go 一樣成立

★★ 兩個代價:
① ★★ 你把「一個有語義的型別」拆成了一堆沒有關聯的基本型別
   ★ processCore(userID string, tier Tier, region string, isVIP bool, …)
   → ★ 這正是 [[過長參數列]] 與 [[基本型別偏執]](★ 見那兩頁)
   → ★★ 而且★ 呼叫端可以傳進「不可能的組合」(★ tier=VIP 但 isVIP=false)

② ★★ 它是一個「為了測試而做的結構妥協」
   → ★ Feathers 自己說這類手法是「臨時的」,★★ 而它常常變成永久的

★★ Go 裡更好的做法通常是「抽出一個值物件」而不是「拆成基本型別」:

// ✅ ★ 不是拆成基本型別,★★ 而是抽出「這個操作真正需要的那些資訊」
type Actor struct {
    UserID string
    Tier   Tier
    Region string
}
func processCore(a Actor, o Order) error { … }     // ★ 兩個參數,★★ 而且有語義
 
func (s *Service) Process(sess *Session, o Order) error {
    return processCore(sess.Actor(), o)             // ★ Session 知道怎麼給出 Actor
}

★★ 這個版本同時達成三件事:

① ★ 核心是純函式 ⇒ 可測(★ 樸素化參數的目標)
② ★★ 參數有語義 ⇒ ★ 不會退化成 [[過長參數列]]
③ ★★ 無法傳進不可能的組合 ⇒ ★ 不變式仍然被守住(見 [[值物件(DDD)]])

→ ★ 所以在 Go 裡:★★ 「樸素化參數」的正確終點是 引入參數物件。

★ 什麼時候真的樸素化成基本型別是對的

① ★★ 只有一兩個值,而且它們天然是基本型別
   ★ processCore(userID string, amount Money) ← ★ 這很乾淨
② ★ 那個「重型別」只是一個容器,沒有不變式
   → ★★ 拆開它不會失去任何保證
③ ★ 過渡期:★★ 先樸素化把測試補起來,★ 再重構成參數物件
   → ★ 這是 Feathers 的原意:★★ 先有測試,再改結構

★★ ③ 是最務實的用法:★ 先用樸素化參數換到測試,
★★ 然後在測試保護下做 引入參數物件——★ 這個順序是對的。

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

書對這一手法的評價明顯偏負面,而且是作者自己在示範完之後直接承認的:「到目前為止,已經可以實現我們想要的特性了,但做法是非常醜陋的。」(p.303)書列出五個問題(p.303):

  1. 暴露了原類別的內部表示。
  2. 令原類別的實作更難理解,因為我們將其實作的一部分推到了一個自由函式中。
  3. 寫了一些沒有測試覆蓋的程式碼(實際上我們是沒法給那個取出持續時間陣列的函式編寫測試)。
  4. 重複資料。
  5. 拖延了問題。我們並未解開領域類別與基礎架構之間的依賴(這一點會給後面的工作帶來很大的影響)。

其他限制:

  • 「儘管有這許多缺點,我們終究還是得以把一個有測試覆蓋的特性加進去了。我並不喜歡這類重構,但如果已經沒有其他選擇的話,也只能這麼辦了。」(p.304)

  • 「樸素化參數手法對程式碼的狀況並無多大改善。總的來說,比較好的辦法是將新程式碼加到原類別上,或使用新生類手法來建立新抽象,充當後續工作的基礎。我使用樸素化參數手法的唯一一次,是當我覺得有信心在後面能騰出時間來把我的類別納入測試時;到那時候,便可以把我的方法放到這個類別上了。」(p.304)

  • 通常它是新生類(54 頁)手法的一個不錯的準備——你可以設想把那個自由函式包覆在一個新類別中的情形(p.304)。

  • 與《重構》的分界:這根本不是在改善結構——書自己說它「對程式碼的狀況並無多大改善」,還會暴露內部表示、複製資料。它存在的唯一理由是:在完全打不進測試的黑洞類別上,仍然把一個有測試覆蓋的新特性加進去。用它等於欠下一筆技術債,並且承諾之後回來還。

  • ★★ Go 裡更好的終點是 引入參數物件 而非「拆成基本型別」:核心仍是純函式,但參數有語義、且無法傳進不可能的組合。

  • ★★ 拆成一堆基本型別會退化成 過長參數列 與 基本型別偏執,而且呼叫端可以傳進矛盾的組合(tier=VIP 但 isVIP=false)。

  • ★★ Feathers 自己標示它是「不理想」且「臨時」的手法,而它常常變成永久的。

  • ★ 最務實的用法是過渡期:先樸素化換到測試,再在測試保護下做 引入參數物件。這個順序是對的。

  • ★ 真正適合的情況:只有一兩個值且天然是基本型別、或那個重型別只是沒有不變式的容器。

  • ★ 它產出的純函式在 Go 的價值是「可以用表格測試窮舉」。

🔗 相關工具