模擬物件並非存根

「模擬物件」一詞已成為一種流行的說法,用來描述模擬真實物件以進行測試的特殊情況物件。現在大多數語言環境都有架構,可以輕鬆建立模擬物件。然而,人們常常沒有意識到的是,模擬物件只是特殊情況測試物件的一種形式,它能啟用不同的測試風格。在本文中,我將說明模擬物件如何運作、它們如何鼓勵基於行為驗證的測試,以及它們周圍的社群如何使用它們來開發不同的測試風格。

2007 年 1 月 2 日



幾年前,我第一次在 極限編程 (XP) 社群中遇到「模擬物件」一詞。從那時起,我越來越常遇到模擬物件。部分原因是,許多模擬物件的領先開發人員在不同的時間點都是 Thoughtworks 的同事。部分原因是我在受 XP 影響的測試文獻中越來越常看到它們。

但是,我常常看到模擬物件被描述得很差。特別是,我常常看到它們與存根混淆在一起,這是測試環境中常見的輔助工具。我理解這種混淆,我也曾將它們視為相似一陣子,但與模擬開發人員的對話逐漸讓一些模擬理解滲透到我這個烏龜殼般的頭顱中。

這種差異實際上是兩個不同的差異。一方面,測試結果驗證方式有所不同:狀態驗證和行為驗證之間的區別。另一方面,測試和設計交互運作的方式有截然不同的哲學,我將其稱為 測試驅動開發 的傳統風格和模擬風格。

常規測試

我將從一個簡單範例開始說明這兩種風格。(此範例使用 Java,但其原則適用於任何物件導向語言。)我們想要取得一個訂單物件並從倉庫物件中填滿它。訂單非常簡單,只有一個產品和數量。倉庫存放不同產品的庫存。當我們要求訂單從倉庫中填滿時,會有兩種可能的回應。如果倉庫中有足夠的產品可以填滿訂單,則訂單會被填滿,而倉庫中該產品的數量會減少適當的數量。如果倉庫中沒有足夠的產品,則訂單不會被填滿,而倉庫中也不會發生任何事情。

這兩種行為暗示了幾個測試,這些看起來像是相當傳統的 JUnit 測試。

public class OrderStateTester extends TestCase {
  private static String TALISKER = "Talisker";
  private static String HIGHLAND_PARK = "Highland Park";
  private Warehouse warehouse = new WarehouseImpl();

  protected void setUp() throws Exception {
    warehouse.add(TALISKER, 50);
    warehouse.add(HIGHLAND_PARK, 25);
  }
  public void testOrderIsFilledIfEnoughInWarehouse() {
    Order order = new Order(TALISKER, 50);
    order.fill(warehouse);
    assertTrue(order.isFilled());
    assertEquals(0, warehouse.getInventory(TALISKER));
  }
  public void testOrderDoesNotRemoveIfNotEnough() {
    Order order = new Order(TALISKER, 51);
    order.fill(warehouse);
    assertFalse(order.isFilled());
    assertEquals(50, warehouse.getInventory(TALISKER));
  }

xUnit 測試遵循典型的四階段順序:設定、執行、驗證、清除。在這種情況下,設定階段部分在 setUp 方法中完成(設定倉庫),部分在測試方法中完成(設定訂單)。呼叫 order.fill 是執行階段。這是物件被激發去做我們想要測試的事情的地方。然後,assert 陳述是驗證階段,用於檢查被執行的的方法是否正確執行其任務。在這種情況下,沒有明確的清除階段,垃圾收集器會隱式地為我們執行此操作。

在設定期間,我們要組合兩種物件。Order 是我們要測試的類別,但為了讓 Order.fill 運作,我們也需要 Warehouse 的一個執行個體。在這種情況下,Order 是我們專注於測試的物件。以測試為導向的人喜歡使用物件待測或系統待測等術語來命名這樣的東西。這兩個術語都難以啟齒,但由於它是一個廣泛接受的術語,所以我會捏著鼻子使用它。遵循 Meszaros,我將使用系統待測,或者更確切地說是縮寫 SUT。

因此,對於這個測試,我需要 SUT(Order)和一個協作者(warehouse)。我需要倉庫出於兩個原因:一是讓被測試的行為完全運作(因為 Order.fill 呼叫倉庫的方法),其次,我需要它進行驗證(因為 Order.fill 的結果之一是倉庫狀態的潛在變更)。當我們進一步探討這個主題時,你會看到我們將在 SUT 和協作者之間做出很多區別。(在本文的早期版本中,我將 SUT 稱為「主要物件」,將協作者稱為「次要物件」)

這種測試方式使用狀態驗證:表示我們透過檢查 SUT 及其合作者在方法執行後的狀態,來判定執行的結果是否正確。我們將會看到,模擬物件能提供不同的驗證方法。

使用模擬物件進行測試

現在我將使用相同的行為,並使用模擬物件。對於這段程式碼,我使用 jMock 函式庫來定義模擬物件。jMock 是 Java 模擬物件函式庫。市面上還有其他模擬物件函式庫,但這是由這項技術的創始者所撰寫的最新函式庫,因此很適合作為入門。

public class OrderInteractionTester extends MockObjectTestCase {
  private static String TALISKER = "Talisker";

  public void testFillingRemovesInventoryIfInStock() {
    //setup - data
    Order order = new Order(TALISKER, 50);
    Mock warehouseMock = new Mock(Warehouse.class);
    
    //setup - expectations
    warehouseMock.expects(once()).method("hasInventory")
      .with(eq(TALISKER),eq(50))
      .will(returnValue(true));
    warehouseMock.expects(once()).method("remove")
      .with(eq(TALISKER), eq(50))
      .after("hasInventory");

    //exercise
    order.fill((Warehouse) warehouseMock.proxy());
    
    //verify
    warehouseMock.verify();
    assertTrue(order.isFilled());
  }

  public void testFillingDoesNotRemoveIfNotEnoughInStock() {
    Order order = new Order(TALISKER, 51);    
    Mock warehouse = mock(Warehouse.class);
      
    warehouse.expects(once()).method("hasInventory")
      .withAnyArguments()
      .will(returnValue(false));

    order.fill((Warehouse) warehouse.proxy());

    assertFalse(order.isFilled());
  }

請先專注於 testFillingRemovesInventoryIfInStock,因為我在後面的測試中採取了一些捷徑。

首先,設定階段非常不同。一開始它分為兩個部分:資料和預期。資料部分設定我們有興趣使用的物件,在這個意義上,它類似於傳統的設定。不同之處在於所建立的物件。SUT 是一樣的 - 一個訂單。然而,合作者不是倉庫物件,而是一個模擬倉庫 - 技術上來說是 Mock 類別的執行個體。

設定的第二部分在模擬物件上建立預期。預期表示在執行 SUT 時,應在模擬物件上呼叫哪些方法。

在所有預期就緒後,我執行 SUT。執行後,我接著進行驗證,這有兩個面向。我針對 SUT 執行斷言 - 與之前大同小異。然而,我也驗證模擬物件 - 檢查它們是否根據預期被呼叫。

這裡的主要區別在於我們如何驗證訂單在與倉庫互動時是否執行正確的動作。透過狀態驗證,我們透過針對倉庫狀態執行斷言來執行此動作。模擬物件使用行為驗證,我們改為檢查訂單是否在倉庫上執行正確的呼叫。我們透過在設定期間告知模擬物件預期,並在驗證期間要求模擬物件驗證其本身,來執行此檢查。只有訂單使用斷言進行檢查,而且如果該方法不變更訂單的狀態,則完全沒有斷言。

在第二個測試中,我執行一些不同的動作。首先,我使用 MockObjectTestCase 中的 mock 方法,而不是建構函式,以不同的方式建立模擬物件。這是 jMock 函式庫中的一種方便方法,表示我不需要在稍後明確呼叫驗證,任何使用方便方法建立的模擬物件都會在測試結束時自動驗證。我也可以在第一個測試中執行此動作,但我想要更明確地顯示驗證,以說明使用模擬物件進行測試的方式。

第二個測試案例中第二個不同的地方是我使用 withAnyArguments 放寬了預期的約束。這樣做的原因是,第一個測試檢查數字是否傳遞到倉庫,因此第二個測試不需要重複測試的該元素。如果稍後需要變更訂單的邏輯,則只會有一個測試失敗,從而簡化了遷移測試的工作。事實證明,我可以完全不使用 withAnyArguments,因為這是預設值。

使用 EasyMock

市面上有許多模擬物件函式庫。我在 Java 和 .NET 版本中都遇到過一個,那就是 EasyMock。EasyMock 也能啟用行為驗證,但與 jMock 有幾處風格上的差異,值得討論。以下是熟悉的測試

public class OrderEasyTester extends TestCase {
  private static String TALISKER = "Talisker";
  
  private MockControl warehouseControl;
  private Warehouse warehouseMock;
  
  public void setUp() {
    warehouseControl = MockControl.createControl(Warehouse.class);
    warehouseMock = (Warehouse) warehouseControl.getMock();    
  }

  public void testFillingRemovesInventoryIfInStock() {
    //setup - data
    Order order = new Order(TALISKER, 50);
    
    //setup - expectations
    warehouseMock.hasInventory(TALISKER, 50);
    warehouseControl.setReturnValue(true);
    warehouseMock.remove(TALISKER, 50);
    warehouseControl.replay();

    //exercise
    order.fill(warehouseMock);
    
    //verify
    warehouseControl.verify();
    assertTrue(order.isFilled());
  }

  public void testFillingDoesNotRemoveIfNotEnoughInStock() {
    Order order = new Order(TALISKER, 51);    

    warehouseMock.hasInventory(TALISKER, 51);
    warehouseControl.setReturnValue(false);
    warehouseControl.replay();

    order.fill((Warehouse) warehouseMock);

    assertFalse(order.isFilled());
    warehouseControl.verify();
  }
}

EasyMock 使用記錄/重播隱喻來設定預期。對於每個您想要模擬的物件,您會建立一個控制和模擬物件。模擬滿足次要物件的介面,控制提供您其他功能。若要指出預期,請在模擬上呼叫方法,並附上您預期的引數。如果您想要傳回值,請接著呼叫控制。完成設定預期後,請在控制上呼叫重播 - 此時模擬完成記錄,並準備回應主要物件。完成後,請在控制上呼叫驗證。

看來,雖然人們一開始常常對記錄/重播隱喻感到困惑,但他們很快就能習慣。它比 jMock 的約束有優勢,因為您對模擬進行實際的方法呼叫,而不是在字串中指定方法名稱。這表示您可以在 IDE 中使用程式碼完成,而且任何方法名稱的重構都會自動更新測試。缺點是您無法有較寬鬆的約束。

jMock 的開發人員正在開發新版本,將使用其他技術讓您使用實際的方法呼叫。

模擬物件與存根之間的差異

當它們首次推出時,許多人很容易將模擬物件與使用存根的常見測試概念混淆。從那時起,人們似乎更了解差異(我希望本文的早期版本有幫助)。然而,要完全了解人們使用模擬的方式,了解模擬和其他類型的測試替身非常重要。(「替身」?如果您對這個新術語感到困惑,請等幾段落,一切就會清楚了。)

當您進行此類測試時,您一次專注於軟體的一個元素,因此常見術語為單元測試。問題在於,要讓單一單元運作,您通常需要其他單元,因此在我們的範例中需要某種倉庫。

在我上面展示的兩種測試樣式中,第一個案例使用真實的倉庫物件,而第二個案例使用模擬倉庫,當然它不是真實的倉庫物件。使用模擬是測試中不使用真實倉庫的一種方式,但此類測試中還有其他形式的非真實物件。

討論此主題的詞彙很快就會變得雜亂無章,使用了各種詞彙:存根、模擬、假造、虛擬。對於本文,我將遵循 Gerard Meszaros 書籍的詞彙。這不是每個人都使用的,但我認為這是一個很好的詞彙,而且由於這是我的文章,我可以選擇使用哪些詞彙。

Meszaros 使用術語測試替身作為任何用於取代真實物件以進行測試目的的假裝物件的通用術語。這個名稱來自電影中的替身概念。(他的目標之一是避免使用任何已經廣泛使用的名稱。)Meszaros 隨後定義了五種類型的替身

  • 虛擬物件被傳遞,但從未實際使用。通常它們僅用於填寫參數清單。
  • 假造物件實際上具有工作實作,但通常會採取一些捷徑,使其不適合生產(記憶體中資料庫是一個很好的範例)。
  • 存根提供罐頭答案來回應測試期間發出的呼叫,通常完全不回應測試中編寫的任何內容。
  • 間諜是存根,它還會根據呼叫方式記錄一些資訊。其中一種形式可能是記錄已傳送多少封訊息的電子郵件服務。
  • 模擬就是我們在此討論的內容:預先編寫預期的物件,這些預期形成它們預期接收的呼叫的規格。

在這些類型的替身中,只有模擬堅持行為驗證。其他替身可以使用,而且通常會使用狀態驗證。模擬在練習階段實際上確實像其他替身一樣表現,因為它們需要讓 SUT 相信它正在與其真實的合作者交談,但模擬在設定和驗證階段有所不同。

為了進一步探討測試替身,我們需要擴充範例。許多人僅在真實物件難以處理時才使用測試替身。測試替身的常見情況是,如果我們表示在無法填寫訂單時,我們想要傳送電子郵件訊息。問題在於我們不希望在測試期間向客戶傳送實際的電子郵件訊息。因此,我們建立電子郵件系統的測試替身,我們可以控制和操作它。

在這裡,我們可以開始看出模擬和存根之間的差異。如果我們要為此郵件行為撰寫測試,我們可能會撰寫像這樣的簡單存根。

public interface MailService {
  public void send (Message msg);
}
public class MailServiceStub implements MailService {
  private List<Message> messages = new ArrayList<Message>();
  public void send (Message msg) {
    messages.add(msg);
  }
  public int numberSent() {
    return messages.size();
  }
}                                 

然後,我們可以像這樣對存根執行狀態驗證。

class OrderStateTester...

  public void testOrderSendsMailIfUnfilled() {
    Order order = new Order(TALISKER, 51);
    MailServiceStub mailer = new MailServiceStub();
    order.setMailer(mailer);
    order.fill(warehouse);
    assertEquals(1, mailer.numberSent());
  }

當然,這是一個非常簡單的測試,只測試訊息是否已傳送。我們沒有測試它是否已傳送給正確的人員,或內容是否正確,但這足以說明重點。

使用模擬,此測試看起來會非常不同。

class OrderInteractionTester...

  public void testOrderSendsMailIfUnfilled() {
    Order order = new Order(TALISKER, 51);
    Mock warehouse = mock(Warehouse.class);
    Mock mailer = mock(MailService.class);
    order.setMailer((MailService) mailer.proxy());

    mailer.expects(once()).method("send");
    warehouse.expects(once()).method("hasInventory")
      .withAnyArguments()
      .will(returnValue(false));

    order.fill((Warehouse) warehouse.proxy());
  }
}

在這兩種情況下,我使用測試替身而不是真實的郵件服務。差異在於存根使用狀態驗證,而模擬使用行為驗證。

為了對存根使用狀態驗證,我需要在存根上建立一些額外的驗證方法。因此,存根實作 MailService,但會新增額外的測試方法。

模擬物件總是使用行為驗證,存根可以朝任一方向發展。Meszaros 將使用行為驗證的存根稱為測試間諜。差異在於替身如何執行和驗證,我會讓您自行探索。

傳統測試與模擬測試

現在,我已經可以探討第二個二分法:古典 TDD 和模擬主義 TDD 之間的二分法。此處最大的問題是何時使用模擬(或其他替身)。

古典 TDD 風格是盡可能使用真實物件,如果使用真實物件很麻煩,則使用替身。因此,古典 TDDer 會使用真實倉庫,並使用替身來處理郵件服務。替身的類型並不重要。

然而,模擬主義 TDD 實務者會始終對具有有趣行為的任何物件使用模擬。在此情況下,對倉庫和郵件服務都使用模擬。

儘管各種模擬架構都是針對模擬主義測試而設計的,但許多古典主義者發現它們對於建立替身很有用。

模擬主義風格的一個重要分支是行為驅動開發(BDD)。BDD 最初是由我的同事 Daniel Terhorst-North 開發的,作為一種技術,以更佳的方式幫助人們學習測試驅動開發,方法是專注於 TDD 如何作為一種設計技術運作。這導致將測試重新命名為行為,以更好地探討 TDD 如何協助思考物件需要做什麼。BDD 採用模擬主義方法,但它擴展了這一點,無論是在命名風格上,還是在其技術中整合分析的渴望上。我不會在此深入探討,因為與本文相關的唯一一點是,BDD 是 TDD 的另一種變體,它傾向於使用模擬主義測試。我會讓您追蹤連結以取得更多資訊。

您有時會看到「底特律」風格用於「古典」,而「倫敦」風格用於「模擬主義」。這暗示了 XP 最初是在底特律的 C3 專案中開發的,而模擬主義風格是由倫敦的早期 XP 採用者開發的。我還應該提到,許多模擬主義 TDD 人員不喜歡這個術語,當然也不喜歡任何暗示古典測試和模擬主義測試之間有不同風格的術語。他們不認為這兩種風格之間有有用的區別。

選擇不同的差異

在本文中,我解釋了一對差異:狀態或行為驗證/古典或模擬主義 TDD。在它們之間做出選擇時,有哪些論點需要記住?我將從狀態與行為驗證選擇開始。

首先要考慮的是背景。我們是在思考一個容易的協作,例如訂單和倉庫,還是困難的協作,例如訂單和郵件服務?

如果是一個容易的協作,那麼選擇很簡單。如果我是一個古典 TDDer,我不會使用模擬、存根或任何類型的替身。我使用真實物件和狀態驗證。如果我是一個模擬主義 TDDer,我使用模擬和行為驗證。完全沒有任何決定。

如果是一個困難的協作,那麼如果我是一個模擬主義者,就沒有決定——我只需要使用模擬和行為驗證。如果我是一個古典主義者,那麼我確實有選擇,但使用哪一個並不是什麼大問題。通常,古典主義者會根據每個情況決定最簡單的途徑,逐案決定。

因此,正如我們所見,狀態與行為驗證大多不是一個重大的決定。真正問題在於古典和模擬主義 TDD 之間。事實證明,狀態和行為驗證的特徵確實會影響討論,而這正是我將把大部分精力集中在的地方。

但在這麼做之前,讓我提出一個極端情況。偶爾您確實會遇到即使不是困難的協作,也很難使用狀態驗證的事情。一個很好的例子就是快取。快取的重點在於您無法從其狀態中判斷快取是否命中或遺漏——對於即使是硬核古典 TDDer 來說,這也是行為驗證將是明智選擇的情況。我敢肯定在兩個方向上還有其他例外。

當我們深入探討古典/模擬主義選擇時,有很多因素需要考慮,所以我將它們分成了粗略的群組。

推動 TDD

模擬物件來自 XP 社群,而 XP 的主要特色之一是其強調測試驅動開發,其中系統設計是透過由撰寫測試所驅動的迭代而演進。

因此,模擬主義者特別討論模擬主義測試對設計的影響,不足為奇。特別是,他們提倡一種稱為需求驅動開發的風格。使用這種風格,您可以透過為系統外部撰寫第一個測試來開始開發使用者故事,將某個介面物件設為您的 SUT。透過思考合作者的預期,您可以探索 SUT 與其鄰居之間的互動,有效地設計 SUT 的傳出介面。

一旦您的第一個測試執行,模擬的預期會提供下一個步驟的規格和測試的起點。您將每個預期轉換為合作者的測試,並重複這個程序,一次一個 SUT 地進入系統。這種風格也被稱為由外而內,這是一個非常貼切的名稱。它適用於分層系統。您首先使用底層的模擬層來編寫 UI。然後您為較低層撰寫測試,一次一層地逐步深入系統。這是一種非常結構化且受控的方法,許多人相信這有助於指導 OO 和 TDD 的新手。

傳統的 TDD 並沒有提供完全相同的指導。您可以使用存根方法而不是模擬來執行類似的逐步方法。為此,每當您需要來自合作者的任何東西時,您只需硬編碼 SUT 運作所需的回應即可。然後,一旦您對此感到滿意,就可以用適當的程式碼取代硬編碼的回應。

但是傳統的 TDD 也可以做其他事情。一種常見的風格是由中而外。在此風格中,您採用一個功能並決定您在網域中需要什麼才能讓此功能運作。您讓網域物件執行您需要它們執行的動作,一旦它們運作,您就會在上面分層放置 UI。執行此操作時,您可能永遠不需要偽造任何東西。許多人喜歡這樣,因為它首先關注網域模型,這有助於防止網域邏輯外洩到 UI 中。

我必須強調,模擬主義者和經典主義者一次只做一個故事。有一種思想學派是逐層建立應用程式,在另一層完成之前不會開始下一層。經典主義者和模擬主義者傾向於有敏捷的背景,並且偏好細緻的迭代。因此,他們會逐項功能地工作,而不是逐層地工作。

固定裝置設定

使用經典 TDD,你必須建立 SUT,以及 SUT 在回應測試時所需的所有合作者。雖然範例中只有幾個物件,但實際測試通常會涉及大量的次要物件。通常這些物件會在每次執行測試時建立和移除。

然而,模擬主義者測試只需要建立 SUT 和其直接鄰近的模擬。這可以避免在建立複雜固定裝置時涉及的部分工作(至少在理論上是如此。我曾聽過一些相當複雜的模擬設定的故事,但那可能是因為沒有善用工具)。

實際上,經典測試人員傾向於盡可能重複使用複雜的固定裝置。最簡單的方法是將固定裝置設定程式碼放入 xUnit 設定方法中。更複雜的固定裝置需要由多個測試類別使用,因此在這種情況下,你會建立特殊的固定裝置產生類別。我通常會稱這些為物件母親,這是根據 Thoughtworks XP 早期專案中使用的命名慣例。在較大型的經典測試中,使用母親是必要的,但母親是需要維護的附加程式碼,而且對母親的任何變更都可能透過測試產生顯著的漣漪效應。在設定固定裝置時也可能會有效能成本 - 儘管我沒有聽說在適當執行時這是一個嚴重問題。大多數固定裝置物件建立起來都很便宜,那些不便宜的通常會加倍。

因此,我聽過這兩種風格都指責另一種工作量太大。模擬主義者說建立固定裝置需要花費很多功夫,但經典主義者說這是重複使用的,但你必須在每個測試中建立模擬。

測試隔離

如果你在模擬主義者測試中向系統引入錯誤,通常只會導致 SUT 包含錯誤的測試失敗。然而,使用經典方法,任何客戶端物件的測試都可能失敗,這會導致在其他物件的測試中將有錯誤的物件用作合作者的情況下發生失敗。因此,在高度使用的物件中發生故障會導致整個系統中的一連串測試失敗。

模擬測試人員認為這是一個主要問題;為了找到錯誤的根源並修復它,這會導致大量除錯。然而,經典主義者並未表示這是一個問題來源。通常,罪魁禍首很容易透過查看哪些測試失敗來發現,而且開發人員可以看出其他失敗是源自根源故障。此外,如果你定期進行測試(正如你應該做的),那麼你就會知道中斷是由你最後編輯的內容所造成的,因此找到故障並不困難。

在此可能重要的因素之一是測試的粒度。由於經典測試會執行多個真實物件,因此你通常會發現單一測試是物件群集的主要測試,而不仅仅是一個物件。如果該群集跨越許多物件,那麼找到錯誤的真正來源可能會困難得多。在此發生的情況是測試過於粗糙。

模擬測試不太可能遇到此問題,因為慣例是模擬出所有超出主要物件的物件,這清楚地表明合作者需要更精細的測試。話雖如此,使用過於粗糙的測試也不一定是經典測試作為一種技術的失敗,而是一種無法正確執行經典測試的失敗。一個好的經驗法則就是確保為每個類別分開精細的測試。雖然群集有時是合理的,但它們應該僅限於極少數物件 - 不超過六個。此外,如果你發現自己因為過於粗糙的測試而遇到除錯問題,你應該以測試驅動的方式進行除錯,在進行的過程中建立更精細的測試。

實質上,經典 xunit 測試不僅是單元測試,也是迷你整合測試。因此,許多人喜歡客戶端測試可以捕捉物件的主要測試可能遺漏的錯誤,特別是探測類別互動的區域。模擬測試會失去這種品質。此外,你還冒著模擬測試上的預期可能不正確的風險,導致單元測試執行正常,但掩蓋了固有的錯誤。

在這裡,我應該強調無論你使用哪種測試樣式,你都必須將它與在整個系統中運作的更粗糙的驗收測試結合起來。我經常遇到使用驗收測試過晚的專案,並對此感到遺憾。

將測試與實作結合

當你撰寫模擬測試時,你正在測試 SUT 的傳出呼叫,以確保它正確地與其供應商通話。經典測試只關心最終狀態 - 而不是該狀態是如何導出的。因此,模擬測試與方法的實作更為相關。改變對合作者呼叫的性質通常會導致模擬測試中斷。

這種耦合會導致幾個問題。最重要的問題是對測試驅動開發的影響。使用 mockist 測試時,撰寫測試會讓您思考行為的實作方式 - 確實,mockist 測試人員將這視為優點。然而,經典主義者認為,只思考外部介面發生的事情很重要,並且在完成測試撰寫之前,不要考慮實作。

與實作的耦合也會干擾重構,因為實作變更比使用經典測試更容易中斷測試。

這可能會因 mock 工具組的性質而惡化。即使與特定測試無關,mock 工具通常會指定非常具體的方法呼叫和參數比對。jMock 工具組的其中一個目標,是在其預期的規格中更具彈性,允許在不重要的區域中放寬預期,但代價是使用可能會讓重構更棘手的字串。

設計風格

對我來說,這些測試樣式的最迷人之處之一,在於它們如何影響設計決策。當我與這兩種類型的測試人員交談時,我意識到樣式鼓勵的設計之間有一些差異,但我確定我僅是略窺皮毛。

我已經提到處理層級的差異。Mockist 測試支援由外而內的做法,而偏好領域模型外樣式的開發人員則傾向偏好經典測試。

在較小的層級上,我注意到 mockist 測試人員傾向於遠離傳回值的函式,而偏好對收集物件採取動作的函式。舉一個從物件群組收集資訊以建立報告字串的行為範例。執行此操作的常見方式,是讓報告方法呼叫各種物件上的傳回字串的方法,並將結果字串組裝在暫時變數中。mockist 測試人員更有可能將字串緩衝區傳遞到各種物件,並讓它們將各種字串新增到緩衝區中 - 將字串緩衝區視為收集參數。

模擬主義測試員更常談論避免「火車事故」- getThis().getThat().getTheOther() 風格的方法鏈。避免方法鏈也稱為遵循迪米特法則。雖然方法鏈是一種異味,但中間人物件充斥著轉發方法的相反問題也是一種異味。(我一直覺得如果迪米特法則改稱為迪米特建議,我會更自在。)

人們在物件導向設計中最難理解的事情之一是 「告訴,不要詢問」原則,它鼓勵你告訴物件執行某項操作,而不是從物件中擷取資料,再於用戶端程式碼中執行。模擬主義者表示,使用模擬主義測試有助於促進這一點,並避免過多當今程式碼中普遍存在的 getter 紙花。古典主義者認為,還有許多其他方法可以做到這一點。

基於狀態驗證公認的問題是,它可能導致建立查詢方法,只為支援驗證。純粹為了測試而將方法新增到物件的 API 中,永遠不會感到自在,行為驗證避免了這個問題。反駁的論點是,此類修改在實務上通常很小。

模擬主義者偏好 角色介面,並聲稱使用這種測試風格會鼓勵更多角色介面,因為每個協作都會個別模擬,因此更有可能轉換為角色介面。因此,在我上面使用字串緩衝區產生報表的範例中,模擬主義者更有可能發明一個在該網域中合理的特定角色,而這可能由字串緩衝區實作。

重要的是要記住,設計風格的這種差異是大多數模擬主義者的主要動機。TDD 的起源是希望獲得強大的自動回歸測試,以支援演化設計。在此過程中,其從業者發現,先撰寫測試對設計流程有顯著的改善。模擬主義者對於哪種設計是好設計有強烈的想法,並且主要開發模擬程式庫,以幫助人們開發這種設計風格。

因此我應該成為傳統主義者還是模擬主義者?

我發現這個問題很難有信心地回答。就個人而言,我一向都是老派的古典 TDD,到目前為止,我沒有看到任何改變的理由。我沒有看到模擬主義 TDD 有任何令人信服的好處,而且擔心測試與實作耦合的後果。

當我觀察到模擬主義程式設計師時,這一點尤其讓我印象深刻。我真的很喜歡在撰寫測試時,專注於行為的結果,而不是如何完成。模擬主義者不斷思考 SUT 將如何實作,以撰寫預期。這對我來說感覺非常不自然。

我也遭受著沒有在任何玩具之外嘗試模擬主義 TDD 的缺點。正如我從測試驅動開發本身學到的,在不認真嘗試的情況下,通常很難判斷一種技術。我確實認識許多優秀的開發人員,他們對模擬主義感到非常滿意和信服。因此,儘管我仍然是一位堅定的古典主義者,但我寧願盡可能公平地提出雙方的論點,以便您可以自己做出決定。

因此,如果您對模擬主義測試感到有吸引力,我建議您嘗試一下。如果您在模擬主義 TDD 旨在改進的某些領域遇到問題,那麼特別值得嘗試。我這裡看到兩個主要領域。一是如果測試失敗時您花費大量時間進行除錯,因為它們沒有乾淨地中斷並告訴您問題出在哪裡。(您也可以通過對更細粒度的群集使用經典 TDD 來改善這一點。)第二個領域是如果您的物件不包含足夠的行為,模擬主義測試可能會鼓勵開發團隊建立更豐富行為的物件。

最後的想法

隨著對單元測試、xunit 框架和測試驅動開發的興趣日益濃厚,越來越多的人開始接觸模擬物件。很多時候,人們會學到一些關於模擬物件框架的知識,而沒有完全理解支撐它們的模擬主義/古典主義分歧。無論您傾向於分歧的哪一方,我認為了解觀點上的這種差異很有用。雖然您不必成為模擬主義者就能找到模擬框架很方便,但了解指導軟體許多設計決策的思維是有用的。

本文的目的在於指出這些差異並闡述它們之間的取捨。模擬主義思維還有更多內容,我沒有時間深入探討,特別是它對設計風格的影響。我希望在未來幾年裡,我們將看到更多關於這方面的著作,這將加深我們對在編寫程式碼之前編寫測試的迷人後果的理解。


進一步閱讀

對於 xunit 測試實務的全面概述,請留意 Gerard Meszaros 即將出版的書籍(免責聲明:它在我的系列中)。他還維護了一個網站,其中包含該書中的模式。

若要瞭解更多有關 TDD 的資訊,首先要看的是Kent 的書

若要瞭解更多有關模擬主義測試風格的資訊,最好的整體資源是Freeman & Pryce。作者負責mockobjects.com。特別閱讀優秀的 OOPSLA 論文。有關行為驅動開發的更多資訊,這是 TDD 的另一種分支,其風格非常模擬主義,請從 Daniel Terhorst-North 的介紹開始。

您也可以透過檢視下列工具網站,進一步了解這些技術:jMocknMockEasyMock,以及.NET EasyMock。(還有其他模擬工具,請勿將此清單視為完整清單。)

XP2000 採用了原始模擬物件論文,但目前已相當過時。

重大修訂

2007 年 1 月 2 日:將原本基於狀態與基於互動的測試區分,拆分成兩個部分:狀態驗證與行為驗證,以及傳統 TDD 與模擬主義 TDD。我也做了各種詞彙變更,以符合 Gerard Meszaros 的 xunit 模式書籍。

2004 年 7 月 8 日:首次發布