本文章是由機器翻譯。

測試為導向的設計

使用 [Mock] 和 [測試] 來設計角色為基礎的物件

Isaiah Perumalla

可從 MSDN 程式庫 的程式碼下載
瀏覽線上的程式碼

本文將告訴您:

  • 測試無法實作的互動
  • 探索角色,並擷取交談
  • 擷取對話內容
  • 重整程式碼來釐清目的
本文將使用下列技術:
以測試為導向的開發,NMock Framework

內容

未的互動實作
讀取條碼
探索角色
完成 [銷售
擷取對話內容
計算回條
取得產品描述
重整
向上換行

模擬物件可以領域的測試導向開發 (TDD) 中協助您找出物件應該播放強調物件何每一個系統內的角色而不是它們的內部結構。這項技術可以用來支援良好的物件導向設計。使用模擬物件以協助設計出會是更更有趣,比一般的使用它們只是來隔離系統從外部相依性作法。

其中一個最重要的優勢的 TDD,就是它強制您想根據其用途,而非實作的物件的介面的設計可以改善您的程式碼的整體設計。模擬物件補充物件導向的系統的 TDD 處理序,可以讓您撰寫測試程式碼的物件,如同它已經擁有一切 3 從其環境。只要填滿的 Mock 的物件的 collaborators 的位置。這可讓您設計物件的 collaborators,在他們扮演之前的任何具體的實作也存在, 的角色的方面的介面。這會導致探索的處理序的物件的 collaborators 介面其中帶入根據由需要所驅動的即時需求的存在。

理先行測試開發使用 Mock 物件的下列您不可以只設計其用途的物件的介面,但是您也可以發現,並設計的介面的物件需要從其 collaborators。

本文將如何使用 Mock 物件 TDD,來設計物件導向的方面的程式碼角色和責任,未分類的物件至類別階層架構。

未的互動實作

其中的基本原則,物件導向程式設計是以當地語系化的狀態儲存在的狀態物件上作業的所有邏輯,隱藏物件的內部結構和它們的狀態轉換。強調,應該是在物件如何與觸發的事件時,環境中的其他物件進行通訊。在練習,這可以是難達成作業。設計物件的結果這種方式會是每個物件會公開不可見的狀態或其內部結構的任何。因為有沒有可見的狀態,您無法查詢任何內部結構] 或 [測試物件的行為進行判斷提示,其狀態時的狀態。唯一看到是該物件與其環境的互動的方式。不過,您可以追蹤這些互動,以確認的物件的行為。

使用模擬物件,可讓您探索如何物件互動其 collaborators 進行判斷提示物件會將正確訊息傳送給其 collaborators,在指定的案例中。這會將焦點及設計的工作從物件的分類和結構化物件與彼此通訊的方式如何。這,依次,磁碟機的 [您的系統設計為 「 告訴,不要求 」 設計每個物件,知道很少結構或其周圍的物件的狀態相關的樣式。這使得系統更更具彈性讓您變更系統的行為,藉由撰寫不同的物件集。

為了說明這項技術,我將會引導您完成示範 TDD 使用模擬物件在簡短範例。

讀取條碼

我正在建立大型 supermarket 鏈結的銷售點系統。產品類別目錄系統會在總公司,並且由 RESTful 的服務存取。第一個功能主要焦點是系統,以識別的項目、 擷取其價格,及計算的銷售總數接收使用條碼資訊可能是以手動方式輸入,或從條碼掃瞄器讀取。

在 cashier 進入使用觸控式螢幕或條碼掃描器的命令時,系統會觸發。命令的輸入裝置會以下列格式字串來傳送:

  • 命令: NewSale;
  • 命令: EndSale;
  • 輸入: 條碼 = 100008888559,Quantity = 1;

所有的條碼,請遵循 UPC 配置 ; 條碼的前六個字元的識別製造商的程式碼,],並接下來的五個字元會識別產品程式碼。產品資訊系統會要求製造商的程式碼和產品程式碼可以擷取項目的產品說明。

系統必須先接收,並解譯從輸入裝置 (鍵盤和掃描器) 的命令。當完成銷售時,系統應該計算,並列印回條,使用從總公司的 「 產品類別目錄 」,以擷取產品描述和價格。

在第一個步驟是解碼,到表示簽出事件的在從輸入裝置傳送的未經處理訊息。我會啟動使用最簡單的命令。初始化新的銷售指令只會觸發新的銷售事件,在系統中。我需要解碼原始訊息從輸入的裝置,並將它們到內容中轉換的物件,表示的應用程式定義域的事件。在第一個測試顯示如下:

[TestFixture]
public class CommandParserTests {

  [Test]
  public void NotifiesListenerOfNewSaleEvent() {
    var commandParser = new CommandParser();
    var newSaleCommand= "Command:NewSale";
    commandParser.Parse(newSaleCommand);
  }
}

請注意 CommandParser 不即使存在此時。 我正在使用測試出這個物件會提供的介面的磁碟機。

我會如何知道這個物件是否被解譯從輸入裝置正確地傳送命令? 下列在 「 告訴,不要求 」 原則,CommandParser 物件應該告知它具有關聯性與新的銷售是的物件啟始。 這時候我不知道使用者,或在它具有與關聯性。 這是尚未被發現。

探索角色

到目前為止,我知道 CommandParser 的 collaborator 所有是它必須知道與銷售相關的事件時偵測到系統中。 我將選擇的名稱,描述就這項功能 — SaleEventListener — 並注意它代表角色,在系統中。 角色可視為一個具名的插槽,可在任何物件,可以滿足角色的責任所填入一個軟體系統中。 若要探索角色,您需要只著重所需的活動,來描述物件的部分中的系統中執行的活動時,請檢查可見物件和其 collaborator 之間互動。

在 C# 中介面可以指定一個角色。 在這個範例中,the CommandParser 會需要從其環境可以播放的 SaleEventListener 角色的物件。 不過,單獨的介面是不適當,來說明如何將物件群組的通訊完成工作。 使用模擬物件,我可以描述這在我的測試如 [圖 1 ] 所示。

[圖 1 定義 SaleEventListener 角色

[TestFixture]
public class CommandParserTests {
  private Mockery mockery;

  [SetUp]
  public void BeforeTest() {
    mockery = new Mockery();
  }

  [Test]
  public void NotifiesListenerOfNewSaleEvent() {
    var saleEventListener = mockery.NewMock<ISaleEventListener>();
    var commandParser = new CommandParser();
    var newSaleCommand = "Command:NewSale";
    commandParser.Parse(newSaleCommand);
  }
}

我會明確地定義的角色 SaleEventListener 使用介面,ISaleEventListener。 我為什麼而非內建的支援 C# 提供的事件委派 (Delegate) 透過使用接聽程式介面?

有很多的原因為什麼我選擇使用事件和委派的接聽程式的介面。 接聽程式介面會明確地識別命令剖析器 (Parser) 合作的角色。 請注意我無法連接,CommandParser 至任何特定的類別,而是利用一個接聽程式介面我指出明確地在 CommandParser 與它必須與該角色之間關係。 事件和委派可能會讓您在的 CommandParser 任意程式碼中攔截,但它不 Express 在網域中物件之間可能的關係。 使用接聽程式介面在這種情況下可讓我使用清楚地表達網域模型物件之間的通訊模式。

在我的範例,CommandParser 就會剖析命令,並傳送的應用程式定義域的不同類型的事件。 這些將會永遠連結在同一時間。 在這種情況下,它會比較比較方便傳遞參考至一個接聽程式介面可處理一組事件,而不是每個不同的事件附加不同的委派的執行個體。 讓 [CommandParser 可以與其進行互動,我再會需要這個介面的執行個體。 我所使用, NMock Framework 若要以動態方式,請建立此介面的執行個體。

我現在需要指定我預期要告訴它已解譯命令從輸入的裝置時,播放 ISaleEventListener 的角色之物件的 CommandParser 物件。 我以指定此 ISaleEventListener Mock 實作撰寫的期望:

[Test]
public void NotifiesListenerOfNewSaleEvent() {
  var saleEventListener = mockery.NewMock<ISaleEventListener>();
  var commandParser = new CommandParser();
  var newSaleCommand = "Command:NewSale";

  Expect.Once.On(saleEventListener).Method("NewSaleInitiated");

  commandParser.Parse(newSaleCommand);
  mockery.VerifyAllExpectationsHaveBeenMet();
}

從其 collaborator,需要撰寫這個介面,CommandParser 出的預期家的動作。 您可以使用 Mock 物件,探索並設計的介面的物件需要從其 collaborators 這些 collaborators 的任何實作也存在。 這可讓您保持專注在 CommandParser 上,而不需擔心其 collaborators 的實作。

若要編譯此測試我需要建立 CommandParser 類別和 ISaleEventListener 介面:

public class CommandParser {
  public void Parse(string messageFromDevice) {
  }
}

public interface ISaleEventListener {
  void NewSaleInitiated();
}

測試編譯,我執行的測試,並得到下列錯誤:

TestCase 'Domain.Tests.CommandParserTests.NotifiesListenerOfNewSaleEvent'
failed: NMock2.Internal.ExpectationException : not all expected invocations were performed
Expected:
  1 time: saleEventListener.NewSaleInitiated(any arguments) [called 0 times]
    at NMock2.Mockery.FailUnmetExpectations()
    at NMock2.Mockery.VerifyAllExpectationsHaveBeenMet()

.NMock Framework 會報告 Mock ISaleEventListener 實作必須是方法叫用一次,NewSaleInitiated,但這不會發生。 若要將測試,我需要將 saleEventListener 的 Mock 執行個體傳遞至 CommandParser 物件做為相依性。

[Test]
public void NotifiesListenerOfNewSaleEvent() {
  var saleEventListener = mockery.NewMock<ISaleEventListener>();
  var commandParser = new CommandParser(saleEventListener);
  var newSaleCommand = "Command:NewSale";

  Expect.Once.On(saleEventListener).Method("NewSaleInitiated");

  ommandParser.Parse(newSaleCommand);
  mockery.VerifyAllExpectationsHaveBeenMet();
}

現在測試明確地指定其指定在 saleEventListener 應該接收哪些訊息 () 方法呼叫) 的環境,CommandParser 具有相依性。

以下是最簡單的實作通過此測試:

public class CommandParser {
  private readonly ISaleEventListener saleEventListener;

  public CommandParser(ISaleEventListener saleEventListener) {
    this.saleEventListener = saleEventListener;
  }

  public void Parse(string messageFromDevice) {
    saleEventListener.NewSaleInitiated();
  }
}

完成 [銷售

現在,測試傳遞,我可以移至下一個測試。 下一個簡單的成功案例將會是測試,CommandParser 可以解碼完成銷售命令,以及通知系統。

[Test]
public void NotifiesListenerOfSaleCompletedEvent() {
  var saleEventListener = mockery.NewMock<ISaleEventListener>();
  var commandParser = new CommandParser(saleEventListener);
  var endSaleCommand = "Command:EndSale";

  Expect.Once.On(saleEventListener).Method("SaleCompleted");

  commandParser.Parse(endSaleCommand);
  mockery.VerifyAllExpectationsHaveBeenMet();
}

出另一個方法的介面必須支援 [ISaleEventListener 磁碟機測試。

public interface ISaleEventListener {
  void NewSaleInitiated();
  void SaleCompleted();
}

執行測試的失敗時,您所預期。 NMock 中,會顯示下列錯誤訊息:

TestCase 'Domain.Tests.CommandParserTests.NotifiesListenerOfSaleCompletedEvent'
failed: NMock2.Internal.ExpectationException : unexpected invocation of saleEventListener.NewSaleInitiated()
Expected:
  1 time: saleEventListener.SaleCompleted(any arguments) [called 0 times]

我需要解譯原始指令,並 saleEventListener 物件的執行個體上呼叫適當的方法。 [圖 2 ] 中的,簡單實作應該取得通過測試。

[圖 2] 的實作 saleEventListener

public class CommandParser {
  private const string END_SALE_COMMAND = "EndSale";
  private readonly ISaleEventListener saleEventListener;

  public CommandParser(ISaleEventListener saleEventListener) {
    this.saleEventListener = saleEventListener;
  }

  public void Parse(string messageFromDevice) {
    var commandName = messageFromDevice.Split(':')[1].Trim();
    if (END_SALE_COMMAND.Equals(commandName))
      saleEventListener.SaleCompleted();
    else
      saleEventListener.NewSaleInitiated();
  }
}

在之前上移動至下一個測試,我移除重複測試程式碼 (請參閱中[圖 3]).

[圖 3 在測試的更新

[TestFixture]
public class CommandParserTests {
  private Mockery mockery;
  private CommandParser commandParser;
  private ISaleEventListener saleEventListener;

  [SetUp]
  public void BeforeTest() {
    mockery = new Mockery();
    saleEventListener = mockery.NewMock<ISaleEventListener>();
    commandParser = new CommandParser(saleEventListener);
    mockery = new Mockery();
  }

  [TearDown]
  public void AfterTest() {
    mockery.VerifyAllExpectationsHaveBeenMet();
  }

  [Test]
  public void NotifiesListenerOfNewSaleEvent() {
    var newSaleCommand = "Command:NewSale";

    Expect.Once.On(saleEventListener).Method("NewSaleInitiated");

    commandParser.Parse(newSaleCommand);
  }

  [Test]
  public void NotifiesListenerOfSaleCompletedEvent() {
    var endSaleCommand = "Command:EndSale";

    Expect.Once.On(saleEventListener).Method("SaleCompleted");

    commandParser.Parse(endSaleCommand);
  }
}

接下來,我要確保在 CommandParser 可以處理的輸入的命令,以條碼資訊。 應用程式會接收未經處理的訊息,以下列格式:

Input:Barcode=100008888559, Quantity =1

我要告訴物件,以播放一個 SaleEventListener 的角色具有條碼的項目],並輸入數量:

[Test]
public void NotifiesListenerOfItemAndQuantityEntered() {
  var message = "Input: Barcode=100008888559, Quantity =1";

  Expect.Once.On(saleEventListener).Method("ItemEntered")
    .With("100008888559", 1);

  commandParser.Parse(message);
}

測試磁碟出需要還 ISaleEventListener 介面必須提供的另一個方法:

public interface ISaleEventListener {
  void NewSaleInitiated();
  void SaleCompleted();
  void ItemEntered(string barcode, int quantity);
}

執行測試時,會產生此錯誤:

TestCase 'Domain.Tests.CommandParserTests.NotifiesListenerOfItemAndQuantityEntered'
failed: NMock2.Internal.ExpectationException : unexpected invocation of saleEventListener.NewSaleInitiated()
Expected:
  1 time: saleEventListener.ItemEntered(equal to "100008888559", equal to <1>) [called 0 times]

錯誤訊息會告訴我 saleEventListener 上呼叫在錯誤的方法。 這是預期我尚未 CommandParser 處理包含條碼和數量的輸入的訊息中實作的任何邏輯的行為。 [圖 4 ] 顯示更新的 CommandParser。

[圖 4] 處理輸入訊息

public class CommandParser {
  private const string END_SALE_COMMAND = "EndSale";
  private readonly ISaleEventListener saleEventListener;
  private const string INPUT = "Input";
  private const string START_SALE_COMMAND = "NewSale";

  public CommandParser(ISaleEventListener saleEventListener) {
    this.saleEventListener = saleEventListener;
  }

  public void Parse(string messageFromDevice) {
    var command = messageFromDevice.Split(':');
    var commandType = command[0].Trim();
    var commandBody = command[1].Trim();

    if(INPUT.Equals(commandType)) {
      ProcessInputCommand(commandBody);
    }
    else {
      ProcessCommand(commandBody);
    }
  }

  private void ProcessCommand(string commandBody) {
    if (END_SALE_COMMAND.Equals(commandBody))
      saleEventListener.SaleCompleted();
    else if (START_SALE_COMMAND.Equals(commandBody)) 
      saleEventListener.NewSaleInitiated();
  }

  private void ProcessInputCommand(string commandBody) {
    var arguments = new Dictionary<string, string>();
    var commandArgs = commandBody.Split(',');

    foreach(var argument in commandArgs) {
      var argNameValues = argument.Split('=');
      arguments.Add(argNameValues[0].Trim(), 
        argNameValues[1].Trim());
    }

    saleEventListener.ItemEntered(arguments["Barcode"], 
      int.Parse(arguments["Quantity"]));
  }
}

擷取對話內容

之前上移動至下一個測試,我必須重整,清理 CommandParser 之間 saleEventListener 互動。我要指定物件和其的應用程式定義域的 collaborators 之間的互動。ItemEntered 訊息列入兩個引數字串,表示條碼] 和 [表示數量的整數。哪些兩個引數真的表示的應用程式網域中?

根據經驗法則: 如果您在物件 collaborators 之間的基本的資料型別周圍傳遞它可能的表示您沒有權限層級的抽象通訊。您要看到基本資料型別是否表示您的網域,您可能會有遺失的概念。

在這種情況下,條碼被分解成 manufactureCode 和表示的項目識別項的 itemCode 中。我可以介紹程式碼中的項目識別項的概念。這應該是可以從一個的條碼建構的不變型別,而且我可以提供 [ItemIdentifier 的腐爛條碼到製造廠商的程式碼和的項目的程式碼]。同樣地,數量應該是值物件,它表示的度量單位,例如,項目的數量無法被權數所測量。

現在,我沒有有需要還分解條碼或處理不同的型別的數量的度量單位。我只將介紹這些值物件,以確保物件之間的通訊仍會在網域的詞彙。我會重整程式碼,在 [測試] 中包含項目識別項和數量的概念。

[Test]
public void NotifiesListenerOfItemAndQuantityEntered() {
  var message = "Input: Barcode=100008888559, Quantity=1";
  var expectedItemid = new ItemId("100008888559");
  var expectedQuantity = new Quantity(1);

  Expect.Once.On(saleEventListener).Method("ItemEntered").With(
    expectedItemid, expectedQuantity);

  commandParser.Parse(message);
}

項目識別碼] 或 [數量] 都不存在。 若要將測試我需要建立這些新的類別和修改程式碼以反映這些新的概念。 我這些型別實作做為值的物件時。 這些物件的識別根據它們保留 (請參閱 [圖 5 ) 的值而定。

[圖 5 ItemID 和數量

public interface ISaleEventListener {
  void SaleCompleted();
  void NewSaleInitiated();
  void ItemEntered(ItemId itemId, Quantity quantity);
}

public class ItemId {
  private readonly string barcode;

  public ItemId(string barcode) {
    this.barcode = barcode;
  }

  public override bool Equals(object obj) {
    var other = obj as ItemId;
    if(other == null) return false;
    return this.barcode == other.barcode;
  }

  public override int GetHashCode() {
    return barcode.GetHashCode();
  }

  public override string ToString() {
    return barcode;
  }
}

public class Quantity {
  private readonly int value;

  public Quantity(int qty) {
    this.value = qty;
  }

  public override string ToString() {
    return value.ToString();
  }

  public override bool Equals(object obj) {
    var otherQty = obj as Quantity;
    if(otherQty == null) return false;
    return value == otherQty.value;
  }

  public override int GetHashCode() {
    return value.GetHashCode();
  }
}

與使用 Mock 物件互動-以測試,您可以使用測試來明確地描述在一個高階的抽象網域的物件之間的通訊協定,以簡化物件和其 collaborators 之間互動。 由於 Mock 會允許您執行這項操作而不需 collaborators,存在的任何具體實作,您可以試試其他共同作業模式之前,您所設計的應用程式定義域的物件之間,協同作業。 也依照檢查,並仔細遵循物件和其 collaborator 之間互動它也協助挖掘出您可能會忽略任何網域的概念。

計算回條

現在我有解碼命令的輸入裝置,為銷售點的事件的物件,我需要的物件,可以回應,並處理這些事件。 這個需求是列印回條,所以我需要一個物件,可以計算回條。 若要填入這些責任,我尋找可以播放的一個 SaleEventListener 角色的物件。 要注意的暫存器概念,並似乎符合一個的 SaleEventListener 的角色,因此我建立了新的類別登錄。 由於這個類別會回應銷售事件,我會使它實作 ISaleEventListener:

public class Register : ISaleEventListener {
  public void SaleCompleted() { }
  public void NewSaleInitiated() { }
  public void ItemEntered(ItemId itemId, Quantity quantity) { }
}

其中一個暫存器的主要的責任是計算回條,並傳送到印表機。 我將 Rig 某些事件,在物件上的叫用其方法。 我啟動與簡單的案例。 計算銷售沒有項目的接收,應該有 0 個。 我要問,問題: 使用者會知道是否登錄已計算項目的總正確? 我的第一個猜猜看會是接收印表機。 我 Express 此程式碼中藉由撰寫測試:

[Test]
public void ReceiptTotalForASaleWithNoItemsShouldBeZero() {
  var receiptPrinter = mockery.NewMock<IReceiptPrinter>();
  var register = new Register();
  register.NewSaleInitiated();

  Expect.Once.On(receiptPrinter).Method("PrintTotalDue").With(0.00);

  register.SaleCompleted();
}  

測試會表示註冊物件會接收印表機列印總數的到期。

移動向前讓我們取得前一個步驟會回,並檢查登錄物件和接收印表機之間的通訊協定中。 PrintTotalDue 方法是註冊物件有意義的? 查看此互動它沒清楚地註冊的責任是關心列印回條。 登錄物件被關於計算接收,然後傳送之物件的接收回條。 我將選擇方法,就該行為的描述名稱: ReceiveTotalDue。 這是註冊物件更有意義的。 在這樣做,我發現註冊所需要的共同作業角色會是一個 ReceiptReceiver,而不是在 ReceiptPrinter。 它可以協助您尋找適當的名稱角色很重要的設計活動,部分設計具有一組形成一個有內聚力而且責任的物件。 我重新撰寫測試,以反映新的角色名稱。

[Test]
public void ReceiptTotalForASaleWithNoItemsShouldBeZero() {
  var receiptReceiver = mockery.NewMock<IReceiptReceiver>();
  var register = new Register();
  register.NewSaleInitiated();

  Expect.Once.On(receiptReceiver).Method("ReceiveTotalDue").With(0.00);

  register.SaleCompleted();
}  

若要取得此編譯,我會建立代表角色 ReceiptReceiver IReceiptReceiver 介面。

public interface IReceiptReceiver {
  void ReceiveTotalDue(decimal amount);
}

當我執行測試時我收到一個的失敗,如預期般。 Mock 的 Framework 會告訴我 ReceiveTotalDue 永遠不會進行方法呼叫。 若要進行測試通過,我需要註冊物件,傳遞一個 IReceiptReceiver 的 Mock 的實作,因此我會變更以反映此相依性測試。

[Test]
public void ReceiptTotalForASaleWithNoItemsShouldBeZero() {
  var receiptReceiver = mockery.NewMock<IReceiptReceiver>();
  var register = new Register(receiptReceiver);
  register.NewSaleInitiated();

  Expect.Once.On(receiptReceiver).Method("ReceiveTotalDue").With(0.00m);

  register.SaleCompleted();
} 

這裡簡單實作應該取得將測試:

public class Register : ISaleEventListener {
  private readonly IReceiptReceiver receiptReceiver;

  public Register(IReceiptReceiver receiver) {
    this.receiptReceiver = receiver;
  }

  public void SaleCompleted() {
    receiptReceiver.ReceiveTotalDue(0.00m);
  }

  public void NewSaleInitiated() { }

  public void ItemEntered(ItemId itemId, Quantity quantity) { }
}

用來表示由於總量基本型別小數點都是只純量,在網域中,有沒有意義。 這真的表示是貨幣值,所以我將會建立代表金錢的不變的值物件程式使用中。 這時候也沒有必要的 multi-Currency 或貨幣值的捨入。 我只會建立金錢類別,包裝十進位值。 當您需要將發生時我可以新增貨幣] 及 [捨入此類別中的規則。 現在,我將保持在目前的工作上的焦點,並修改以反映此程式碼。 實作如 [圖 6 ] 所示。

[圖 6 使用 Money

public interface IReceiptReceiver {
  void ReceiveTotalDue(Money amount);
}

public class Register : ISaleEventListener {
  private readonly IReceiptReceiver receiptReceiver;

  public Register(IReceiptReceiver receiver) {
    this.receiptReceiver = receiver;
  }

  public void SaleCompleted() {
    receiptReceiver.ReceiveTotalDue(new Money(0.00m));
  }

  public void NewSaleInitiated() { }

  public void ItemEntered(ItemId itemId, Quantity quantity) { }
}
[Test]
public void ReceiptTotalForASaleWithNoItemsShouldBeZero() {
  var receiptReceiver = mockery.NewMock<IReceiptReceiver>();
  var register = new Register(receiptReceiver);
  register.NewSaleInitiated();

  var totalDue = new Money(0m);
  Expect.Once.On(receiptReceiver).Method("ReceiveTotalDue")
    .With(totalDue);
  register.SaleCompleted();
}  

下一個測試,將擴充出某些登錄物件上的其他行為。 登錄不應該計算回條如果無法初始化新的銷售因此我撰寫測試,以指定這個行為:

[SetUp]
public void BeforeTest() {
  mockery = new Mockery();
  receiptReceiver = mockery.NewMock<IReceiptReceiver>();
  register = new Register(this.receiptReceiver);
}

[Test]
public void ShouldNotCalculateRecieptWhenThereIsNoSale() {
  Expect.Never.On(receiptReceiver);
  register.SaleCompleted();
}

這項測試會明確指定,receiptReceiver 應該永遠不會收到任何在其上的方法呼叫。 測試失敗,如預期般,發生下列錯誤:

TestCase 'Domain.Tests.RegisterTests.  ShouldNotCalculateRecieptWhenThereIsNoSale'
failed: NMock2.Internal.ExpectationException : unexpected invocation of   receiptReceiver.ReceiveTotalDue(<0.00>)

登錄物件具有來追蹤某些狀態才能傳遞這項測試 — 來追蹤是否有將銷售正在進行中。 我可以進行測試傳遞具有實作 [圖 7 ] 所示。

[圖 7 追蹤的狀態,以註冊

public class Register : ISaleEventListener {
  private readonly IReceiptReceiver receiptReceiver;
  private bool hasASaleInprogress;

  public Register(IReceiptReceiver receiver) {
    this.receiptReceiver = receiver;
  }

  public void SaleCompleted() {
    if(hasASaleInprogress)
      receiptReceiver.ReceiveTotalDue(new Money(0.00m));
  }

  public void NewSaleInitiated() {
    hasASaleInprogress = true;
  }

  public void ItemEntered(ItemId itemId, Quantity quantity) { }
}

取得產品描述

產品的資訊系統位於標頭為 RESTful 服務公開的 Office 中。 註冊物件將會需要從這個系統,才能設定銷售的接收中擷取產品資訊。 我不想要會受到這個外部系統的實作詳細資料時,所以我將會在網域的詞彙定義我自己的介面,服務銷售系統的需要。

我會撰寫來計算的項目數與銷售接收測試。 若要出的銷售總數的工作,登錄需要共同作業與另一個物件。 要擷取項目的 [A] 產品說明,我會介紹產品目錄的角色。 這可以 RESTful 的服務、 一個的資料庫或某些其他系統的。 實作詳細資料不重要,也有任何問題,註冊物件。 我要設計的註冊物件有意義的介面。 測試如 [圖 8 ] 所示。

[圖 8 測試註冊

[TestFixture]
public class RegisterTests {
  private Mockery mockery;
  private IReceiptReceiver receiptReceiver;
  private Register register;
  private readonly ItemId itemId_1 = new ItemId("000000001");
  private readonly ItemId itemId_2 = new ItemId("000000002");
  private readonly 
    ProductDescription descriptionForItemWithId1 = 
    new ProductDescription("description 1", new Money(3.00m));

  private readonly 
    ProductDescription descriptionForItemWithId2 = 
    new ProductDescription("description 2", new Money(7.00m));
  private readonly Quantity single_item = new Quantity(1);
  private IProductCatalog productCatalog;

  [SetUp]
  public void BeforeTest() {
    mockery = new Mockery();
    receiptReceiver = mockery.NewMock<IReceiptReceiver>();
    productCatalog = mockery.NewMock<IProductCatalog>();
    register = new Register(receiptReceiver, productCatalog);

    Stub.On(productCatalog).Method("ProductDescriptionFor")
      .With(itemId_1)
      .Will(Return.Value(descriptionForItemWithId1));

    Stub.On(productCatalog).Method("ProductDescriptionFor")
      .With(itemId_2)
      .Will(Return.Value(descriptionForItemWithId2));

  }

  [TearDown]
  public void AfterTest() {
    mockery.VerifyAllExpectationsHaveBeenMet();
  }
...
  [Test]
  public void 
    ShouldCalculateRecieptForSaleWithMultipleItemsOfSingleQuantity() {

    register.NewSaleInitiated();
    register.ItemEntered(itemId_1, single_item);
    register.ItemEntered(itemId_2, single_item);

    Expect.Once.On(receiptReceiver).Method("ReceiveTotalDue")
      .With(new Money(10.00m));

   register.SaleCompleted();
  }
}

這項測試會設計所需的登錄物件,productCatalog 之介面。 我也會發現需要新的型別 productDescription 表示產品的描述。 我將為值的物件 (不變的型別) 模型。 我 Stub productCatalog,提供一個 productDescription 查詢的 ItemIdentifier 時。 我 Stub productCatalog ProductDescriptionFor 方法引動的過程,因為這是會傳回在 productDescription 查詢方法。 傳回查詢的結果上作用的登錄。 什麼是很重要這裡,ProductCatalog 傳回指定的 ProductDescription 時登錄會產生正確的副作用。 測試的其餘部分驗證正確的總計算正確地傳送到接收的接收者。

我執行測試,以取得預期的失敗:

unexpected invocation of receiptReceiver.ReceiveTotalDue(<0.00>)
Expected:
  Stub: productCatalog.ProductDescriptionFor(equal to <000000001>), will
       return <Domain.Tests.ProductDescription> [called 0 times]
  Stub: productCatalog.ProductDescriptionFor(equal to <000000002>), will 
      return <Domain.Tests.ProductDescription> [called 0 times]
  1 time: receiptReceiver.ReceiveTotalDue(equal to <10.00>) [called 0 times]

Mock 的 Framework 會告訴我 receiptReceiver 應該收到總數 10,但接收到 0。 由於我們尚未實作任何可以計算總預期。 圖 9 ] 顯示在第一個嘗試在實作取得通過測試。

[圖 9 計算總數

public class Register : ISaleEventListener {
  private readonly IReceiptReceiver receiptReceiver;
  private readonly IProductCatalog productCatalog;
  private bool hasASaleInprogress;
  private List<ProductDescription> purchasedProducts = 
    new List<ProductDescription>();

  public Register(IReceiptReceiver receiver, 
    IProductCatalog productCatalog) {

    this.receiptReceiver = receiver;
    this.productCatalog = productCatalog;
  }

  public void SaleCompleted() {
    if(hasASaleInprogress) {
      Money total = new Money(0m);
      purchasedProducts.ForEach(item => total += item.UnitPrice);
      receiptReceiver.ReceiveTotalDue(total);
    }
  }

  public void NewSaleInitiated() {
    hasASaleInprogress = true;
  }

  public void ItemEntered(ItemId itemId, Quantity quantity) {
    var productDescription = productCatalog.ProductDescriptionFor(itemId);
    purchasedProducts.Add(productDescription);
  }
}

這個程式碼無法編譯,因為 Money 類別不定義將 Money 的作業。 我現在會有金錢物件,讓我撰寫金錢) 類別,以處理此即時需求快速測試來執行加法的需要。

[TestFixture]
public class MoneyTest {

  [Test]
  public void ShouldBeAbleToCreateTheSumOfTwoAmounts() {
    var twoDollars = new Money(2.00m);
    var threeDollars = new Money(3m);
    var fiveDollars = new Money(5m);
    Assert.That(twoDollars + threeDollars, Is.EqualTo(fiveDollars));
  }
}

實作,以取得傳遞這項測試顯示中 [圖 10 .

[圖 10 的更新,新增 Money

public class Money {
  private readonly decimal amount;

  public Money(decimal value) {
    this.amount = value;
  }

  public static Money operator +(Money money1, Money money2) {
    return new Money(money1.amount + money2.amount);
  }

  public override string ToString() {
    return amount.ToString();
  }

  public override bool Equals(object obj) {
    var otherAmount = obj as Money;
    if(otherAmount == null) return false;
    return amount == otherAmount.amount;
  }

  public override int GetHashCode() {
    return amount.GetHashCode();
  }
}

重整

之前請將任何進一步,我要釐清我的程式碼的目的。我的測試沒有追蹤其中一個註冊物件的內部詳細資料。所有這些都是隱藏的註冊物件,讓我重整,釐清的程式碼內。

註冊物件所管理,目前的銷售與相關的狀態,但不會有表示銷售的概念的物件。我決定要擷取銷售類別來管理所有的狀態和銷售相關的行為。重整的程式碼如 [圖 11 ] 所示。

[圖 11 加入銷售類別

public class Register : ISaleEventListener {
  private readonly IReceiptReceiver receiptReceiver;
  private readonly IProductCatalog productCatalog;
  private Sale sale;

  public Register(IReceiptReceiver receiver, 
    IProductCatalog productCatalog) {

    this.receiptReceiver = receiver;
    this.productCatalog = productCatalog;
  }

  public void SaleCompleted() {
    if(sale != null) {
      sale.SendReceiptTo(receiptReceiver);
    }
  }

  public void NewSaleInitiated() {
    sale = new Sale();
  }

  public void ItemEntered(ItemId itemId, Quantity quantity) {
    var productDescription = 
      productCatalog.ProductDescriptionFor(itemId);
    sale.PurchaseItemWith(productDescription);
  }
}

public class Sale {
  private readonly List<ProductDescription> itemPurchased = 
    new List<ProductDescription>();

  public void SendReceiptTo(IReceiptReceiver receiptReceiver) {
    var total = new Money(0m);
    itemPurchased.ForEach(item => total += item.UnitPrice);
    receiptReceiver.ReceiveTotalDue(total);
  }

  public void PurchaseItemWith(ProductDescription description) {
    itemPurchased.Add(description);
  }
}

值物件

in.NET 的術語 實值型別參考,例如 int、 bool、 結構、 列舉等等的 CLR 支援基本型別。這不應混淆與值的物件,這些物件,描述項目。請注意的重要值的物件可以實作的類別 (參考型別) ; 這些物件是不變的而且有沒有概念性的識別。因為值的物件沒有個別的識別碼,兩個值的物件視為相等,如果它們有完全相同的狀態。

向上換行

這個範例會顯示如何模擬物件,並 TDD 可以指引的物件導向程式設計。有很多的這個反覆探索處理序的重要的端的好處。使用 Mock 物件不僅能幫我發現,collaborators 和物件的需求,但我卻也能夠描述,並使明確在我的測試中,在物件的環境必須支援的角色] 和 [測試物件和其 collaborators 之間的通訊模式。

請注意測試顯示只物件用途方面的網域的概念。指定沒有登錄如何互動銷售的測試。這些是有關如何結構化登錄物件的內部詳細資料,完成其工作時,並保留隱藏的這些實作詳細資料]。我選擇 Mock,明確指定應該在系統中物件和其 collaborators 之間的外部可見的互動。

測試使用 Mock 物件會配置一組指定訊息的物件可以傳送和時應該傳送這些訊息的條件約束。網域的概念方面的清楚說明物件之間的互動。包含的窄的角色的介面,讓系統的隨插即用的網域,以及系統的行為可以輕鬆地變更,藉由變更物件的撰寫]。

沒有任何物件公開他們的內部狀態或結構,並且只不可變動狀態 (例如金錢和 itemId 值物件) 會傳遞解決]。這個容易程式維護及修改。注意的是您通常會啟動開發與失敗的接受,其中會有驅動需求和在 CommandParser 在我的範例。

Isaiah Perumalla 資深開發人員在 ThoughtWorks,他是參與提供企業級的軟體使用物件導向設計和測試導向開發。