有時開始為一個類別撰寫測試很容易,但在 legacy code 中往往很困難。Dependencies 可能很難打破。當你需要新增一個功能,卻發現需要修改三、四個緊密相關的類別時,每個都要花好幾小時才能放入測試——你真的必須為每個類別都打破 dependencies 嗎?

答案是:不一定。通常可以「退一層」,找到一個地方同時為多個變更撰寫測試。

Higher-level tests 在重構中非常有用。雖然不應該取代 unit tests,但它們是邁向 unit tests 的第一步。

Interception Points(攔截點)#

Interception point 是程式中你可以偵測到特定變更效果的點。在某些應用程式中容易找到,在另一些中則較困難。

尋找 Interception Points 的方法#

  1. 確定你需要變更的位置
  2. 從這些 change points 開始向外追蹤效果
  3. 每個你能偵測效果的地方都是一個 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());
}

這個測試同時涵蓋 BillingStatementInvoiceItem 三個類別。

Pinch Points(瓶頸點)#

Pinch point 是 effect sketch 中的一個收窄處——一個可以透過少數方法的測試來偵測多個方法變更的地方。

Pinch Point 定義: Pinch point 是 effect sketch 中的收窄處,一個可以透過對少數方法的測試來偵測許多方法中變更的地方。

在帳務系統範例中,BillingStatement.makeStatement 就是一個 pinch point:所有來自 Invoice.constructorInvoice.shippingPricerInvoice.getValueItem.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 依賴 getTokenhasMoreTokens,但不直接依賴 stringToParsecurrentPosition。這裡有一個天然的封裝邊界——可以將 getTokenhasMoreTokens 以及相關欄位提取成 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 處的測試可以被刪除,讓每個類別自己的測試來支撐開發工作。