概述#

將 class 放入 test harness 是最困難的事情之一。最常見的四個問題:

  1. 物件無法輕易建立
  2. Test harness 無法順利 build
  3. Constructor 有不良副作用
  4. Constructor 中發生重要的工作,我們需要感知它

本章透過一系列不同語言的案例,展示如何處理這些問題。

判斷 class 是否能放入 test harness 的最好方法是:直接試試看。寫一個 test case,嘗試建立物件,讓 compiler 告訴你需要什麼。


The Case of the Irritating Parameter#

情境#

一個 Java 的 CreditValidator class,constructor 需要三個參數:

public class CreditValidator {
    public CreditValidator(RGHConnection connection,
                           CreditMaster master,
                           String validatorID) {
        ...
    }

    Certificate validateCustomer(Customer customer)
            throws InvalidCredit {
        ...
    }
}
  • RGHConnection 建構時會連接伺服器(耗時且不穩定)
  • CreditMaster 從檔案載入資料(相對無害)

Figure 9.1: RGHConnection

解法:Extract Interface#

RGHConnectionirritating parameter – 它阻礙了測試。使用 Extract Interface 抽取出只包含業務方法的 interface:

public class FakeConnection implements IRGHConnection {
    public RFDIReport report;

    public void connect() {}
    public void disconnect() {}
    public RFDIReport RFDIReportFor(int id) { return report; }
    public ACTIOReport ACTIOReportFor(int customerID) { return null; }
}

Figure 9.2: RGHConnection after extracting an interface

現在可以在測試中使用 fake:

void testNoSuccess() throws Exception {
    CreditMaster master = new CreditMaster("crm2.mas", true);
    IRGHConnection connection = new FakeConnection();
    CreditValidator validator = new CreditValidator(
                                    connection, master, "a");
    connection.report = new RFDIReport(...);

    Certificate result = validator.validateCustomer(new Customer(...));
    assertEquals(Certificate.VALID, result.getStatus());
}

Pass Null 技巧#

如果發現某個參數在測試的方法中根本不會被使用,可以直接傳 null

CreditValidator validator = new CreditValidator(connection, null, "a");

Pass Null 只適用於測試程式碼。在生產程式碼中不要傳 null,除非別無選擇。在 Java 和 C# 中,如果 null 被使用會拋出例外,test harness 會捕捉到。但在 C 和 C++ 中,null reference 可能導致靜默的記憶體損壞。

Null Object Pattern#

在生產程式碼中,如果經常需要處理 null,考慮使用 Null Object Pattern:建立一個什麼都不做的子類別(如 NullEmployee),避免散佈 null 檢查。

其他替代方案#

  • Subclass and Override Method – 如果依賴不是硬編碼在 constructor 中,可以子類別化並覆寫產生依賴的方法

The Case of the Hidden Dependency#

情境#

C++ 的 mailing_list_dispatcher class 在 constructor 中直接 new 了一個 mail_service

mailing_list_dispatcher::mailing_list_dispatcher()
: service(new mail_service), status(MAIL_OKAY)
{
    const int client_type = 12;
    service->connect();
    if (service->get_status() == MS_AVAILABLE) {
        service->register(this, client_type, MARK_MESSAGES_OFF);
        service->set_param(client_type, ML_NOBOUNCE | ML_REPEATOFF);
    }
    else
        status = MAIL_OFFLINE;
}

問題:無法在不連接郵件系統的情況下建立物件。

解法:Parameterize Constructor#

將隱藏的依賴外部化,透過參數傳入:

mailing_list_dispatcher::mailing_list_dispatcher(mail_service *service)
: status(MAIL_OKAY)
{
    const int client_type = 12;
    service->connect();
    // ... 同上
}

保留原始 constructor 的簽名,讓既有客戶端不受影響:

mailing_list_dispatcher::mailing_list_dispatcher()
{
    initialize(new mail_service);
}

在 C# 和 Java 中更簡潔:

public class MailingListDispatcher {
    public MailingListDispatcher()
    : this(new MailService())
    {}

    public MailingListDispatcher(MailService service) {
        ...
    }
}

Parameterize Constructor 是最容易應用的技巧之一。搭配 Extract Interface 可以讓你傳入 fake 的 service 來測試。


The Case of the Construction Blob#

情境#

Constructor 中建立了大量物件,而且物件之間互相依賴:

class WatercolorPane {
public:
    WatercolorPane(Form *border, WashBrush *brush, Pattern *backdrop)
    {
        anteriorPanel = new Panel(border);
        anteriorPanel->setBorderColor(brush->getForeColor());
        backgroundPanel = new Panel(border, backdrop);

        cursor = new FocusWidget(brush, backgroundPanel);
        ...
    }
};

cursor 嵌入在一堆物件建立的流程中,無法輕易替換。

Figure 9.3: SchedulingTask

解法一:Extract and Override Factory Method#

在 Java 和 C# 中可用,但在 C++ 的 constructor 中不可用(虛擬函數在 constructor 中不會呼叫衍生類別的版本)。

解法二:Supersede Instance Variable#

寫一個 setter 方法,讓我們在建構後替換物件:

void supersedeCursor(FocusWidget *newCursor) {
    delete cursor;
    cursor = newCursor;
}

測試時:

TEST(renderBorder, WatercolorPane) {
    TestingFocusWidget *widget = new TestingFocusWidget;
    WatercolorPane pane(form, border, backdrop);
    pane.supersedeCursor(widget);

    LONGS_EQUAL(0, pane.getComponentCount());
}

在 C++ 中使用 Supersede Instance Variable 時要特別小心記憶體管理。替換物件時必須正確 delete 舊物件。在其他有 GC 的語言中則安全得多。但無論如何,不應在生產程式碼中使用 superseding method。


The Case of the Irritating Global Dependency#

情境#

Facility class 的 constructor 使用了 singleton PermitRepository

public class Facility {
    public Facility(int facilityCode, String owner,
                    PermitNotice notice) throws PermitViolation {
        Permit associatedPermit =
            PermitRepository.getInstance().findAssociatedPermit(notice);
        // ...
    }
}

Singleton 的問題:

  • 不透明 (Opacity) – 看一段程式碼時無法知道它會影響什麼全域狀態
  • 難以測試 – singleton 設計上只允許一個實例,但測試需要每個測試獨立

Figure 9.4: The Permit hierarchy

解法一:Introduce Static Setter#

在 singleton 上新增 setTestingInstance 方法:

public class PermitRepository {
    private static PermitRepository instance = null;

    protected PermitRepository() {}

    public static void setTestingInstance(PermitRepository newInstance) {
        instance = newInstance;
    }

    public static PermitRepository getInstance() {
        if (instance == null) {
            instance = new PermitRepository();
        }
        return instance;
    }
}

測試時:

public void setUp() {
    PermitRepository repository = new PermitRepository();
    // add permits to the repository
    PermitRepository.setTestingInstance(repository);
}

解法二:Subclass and Override Method#

建立測試用的子類別:

public class TestingPermitRepository extends PermitRepository {
    private Map permits = new HashMap();

    public void addAssociatedPermit(PermitNotice notice, Permit permit) {
        permits.put(notice, permit);
    }

    public Permit findAssociatedPermit(PermitNotice notice) {
        return (Permit)permits.get(notice);
    }
}

解法三:Extract Interface#

PermitRepository 使用 Extract Interface,讓所有程式碼依賴 IPermitRepository interface:

public class PermitRepository implements IPermitRepository {
    private static IPermitRepository instance = null;
    // ...
}

Figure 9.5: Permit hierarchy with extract interfaces

關於放鬆 Singleton 限制#

人們使用 singleton 的主要原因通常不是技術上的唯一性需求,而是因為不想費心傳遞全域變數。如果打破 singleton 屬性不會造成嚴重問題,可以直接將 constructor 設為 protected 或 public,依靠團隊規範來確保只有一個實例。


The Case of the Horrible Include Dependencies#

C++ 的 include 機制是導致 build 緩慢的主要原因。一個 header 可以 include 另一個 header,層層累加後,一個小檔案可能間接 include 了數萬行程式碼。

解決策略#

  • 盡量在 header 中使用前向宣告 (forward declaration) 而非 include
  • 使用 Extract Interface 建立只包含純虛擬函數的 header
  • 將 implementation 和 interface 分離到不同的 header 檔案

The Case of the Onion Parameter#

情境#

建立一個物件需要另一個物件,而那個物件又需要另一個物件… 像洋蔥一樣層層包裹。

解法#

  • 使用 Extract Interface 在方便的層級切斷依賴鏈
  • 使用 Pass Null 繞過不需要的參數

The Case of the Aliased Parameter#

情境#

一個參數的型別是來自第三方函式庫的 class,而該 class 又是許多其他 class 的基底 class。你無法輕易 extract interface 或 subclass。

解法#

  • 如果語言支援,使用 Extract Interface 直接在第三方 class 上
  • 或者建立一個 wrapper class 來封裝第三方 class,讓你能控制 interface
  • 在 C++ 中,可以用 Subclass and Override Method 來避開問題

總結#

將 class 放入 test harness 的常見策略:

問題常用技巧
Irritating ParameterExtract Interface, Pass Null
Hidden DependencyParameterize Constructor
Construction BlobExtract and Override Factory Method, Supersede Instance Variable
Irritating Global DependencyIntroduce Static Setter, Subclass and Override Method, Extract Interface
Horrible Include DependenciesForward declaration, Extract Interface
Onion ParameterExtract Interface, Pass Null
Aliased ParameterWrapper class, Subclass and Override Method