有時開始為一個類別撰寫測試很容易,但在 legacy code 中往往很困難。Dependencies 可能很難打破。當你需要新增一個功能,卻發現需要修改三、四個緊密相關的類別時,每個都要花好幾小時才能放入測試——你真的必須為每個類別都打破 dependencies 嗎?
答案是:不一定。通常可以「退一層」,找到一個地方同時為多個變更撰寫測試。
Higher-level tests 在重構中非常有用。雖然不應該取代 unit tests,但它們是邁向 unit tests 的第一步。
Interception Points(攔截點)#
Interception point 是程式中你可以偵測到特定變更效果的點。在某些應用程式中容易找到,在另一些中則較困難。
尋找 Interception Points 的方法#
- 確定你需要變更的位置
- 從這些 change points 開始向外追蹤效果
- 每個你能偵測效果的地方都是一個 interception point,但不一定是最佳的
簡單案例:Invoice#
假設要修改 Invoice 類別的運費計算方式,將邏輯提取到新的 ShippingPricer 類別:
// 修改前
public class Invoice {
public Money getValue() {
Money total = itemsSum();
if (billingDate.after(Date.yearEnd(openingDate))) {
if (originator.getState().equals("FL") ||
originator.getState().equals("NY"))
total.add(getLocalShipping());
else
total.add(getDefaultShipping());
}
else
total.add(getSpanningShipping());
total.add(getTax());
return total;
}
}// 修改後
public class Invoice {
public Money getValue() {
Money total = itemsSum();
total.add(shippingPricer.getPrice());
total.add(getTax());
return total;
}
}透過效果草圖分析:getValue 影響 BillingStatement.makeStatement。最好的 interception point 是直接測試 Invoice.getValue,因為它最接近變更點。

Figure 12.1: getValue affects BillingStatement.makeStatement

Figure 12.2: Effects on getValue

Figure 12.3: A chain of effects
一般而言,選擇靠近 change points 的 interception points 比較好。原因有二:
- 安全性:change point 到 interception point 之間的每一步都是邏輯推理的一環,步驟越多越容易出錯
- 設置成本:距離越遠的 interception points 通常越難設置測試
Higher-Level Interception Points#
最常見的 interception point 是被修改類別上的 public method,但有時它們不是最佳選擇。
考慮擴展計費系統:除了修改 Invoice 的運費計算,還要修改 Item 類別(新增 shipping carrier 欄位)和 BillingStatement(新增按 shipper 分列的明細)。

Figure 12.4: Expanded billing system
如果每個類別都沒有測試,可以為每個類別各自寫測試,但更有效率的方式是找一個 higher-level interception point 來同時 characterize 這組類別。
void testSimpleStatement() {
Invoice invoice = new Invoice();
invoice.addItem(new Item(0, new Money(10)));
BillingStatement statement = new BillingStatement();
statement.addInvoice(invoice);
assertEquals("", statement.makeStatement());
}這個測試同時涵蓋 BillingStatement、Invoice 和 Item 三個類別。
Pinch Points(瓶頸點)#
Pinch point 是 effect sketch 中的一個收窄處——一個可以透過少數方法的測試來偵測多個方法變更的地方。
Pinch Point 定義: Pinch point 是 effect sketch 中的收窄處,一個可以透過對少數方法的測試來偵測許多方法中變更的地方。
在帳務系統範例中,BillingStatement.makeStatement 就是一個 pinch point:所有來自 Invoice.constructor、Invoice.shippingPricer、Invoice.getValue、Item.shippingCarrier 的效果都可以透過它來偵測。

Figure 12.5: Billing system effect sketch
找不到 Pinch Point 時怎麼辦#
- 一個類別或方法可能直接影響數十個事物,其 effect sketch 看起來像大型糾結的樹
- 此時應重新審視 change points——也許你試圖同時處理太多改變
- 考慮每次只找一兩個改變的 pinch points
- 如果真的找不到 pinch point,就盡量在接近 change point 的地方寫測試
尋找 Pinch Point 的另一種方式#
在 effect sketch 中尋找共同使用模式。一個方法或變數可能有三個使用者,但不一定代表有三種不同的使用方式。關鍵問題是:「如果我在這個地方破壞了這個方法,我能在那個地方感知到嗎?」

Figure 12.6: Billing system with inventory

Figure 12.7: Full billing system scenario
Judging Design with Pinch Points(用 Pinch Points 評估設計)#
Pinch points 不只對測試有用,它們還能告訴你如何改善設計。
Pinch point 本質上就是一個天然的封裝邊界。 當你找到一個 pinch point,你就找到了一大段程式碼效果的狹窄漏斗。如果 BillingStatement.makeStatement 是一堆 invoices 和 items 的 pinch point,那麼當 statement 不符合預期時,問題一定出在 BillingStatement 類別或其 invoices 和 items 中。
使用 Effect Sketches 發現隱藏的類別: 在一個大型類別的效果草圖中,尋找自然的封裝邊界。如果有 bubbles 聚集在一個邊界內,想想那群方法和變數可以叫什麼名字——它可能就是一個新類別的名字。
範例:Parser 類別#
public class Parser {
private Node root;
private int currentPosition;
private String stringToParse;
public void parseExpression(String expression) { .. }
private Token getToken() { .. }
private boolean hasMoreTokens() { .. }
}如果畫出效果草圖,parseExpression 依賴 getToken 和 hasMoreTokens,但不直接依賴 stringToParse 或 currentPosition。這裡有一個天然的封裝邊界——可以將 getToken 和 hasMoreTokens 以及相關欄位提取成 Tokenizer 類別。
Pinch Point Traps(Pinch Point 陷阱)#
撰寫 unit tests 時可能遇到一個陷阱:讓 unit tests 逐漸膨脹成 mini-integration tests。
- 為了測試一個類別,我們實例化它的多個 collaborators,結果測試的是整個物件叢集
- 如果過於頻繁地這樣做,最終會得到一堆又大又慢的 unit tests
新舊程式碼的不同策略#
- 新程式碼:盡可能獨立地測試各個類別,必要時 fake out collaborators
- 既有程式碼:先在 pinch point 建立較大範圍的測試,再逐步建立更細粒度的 unit tests
Pinch point 處的測試是暫時性的。它們像是走進一片森林幾步後畫一條線說「我擁有這整個區域」。在你掌控了這個區域後,就應該透過重構和撰寫更多測試來開發它。最終,pinch point 處的測試可以被刪除,讓每個類別自己的測試來支撐開發工作。