具有复杂设置和逻辑的单元测试

Unit Testing with Complex Setup and Logic

我正努力在单元测试方面变得更好,我最大的不确定性之一是为需要大量设置代码的方法编写单元测试,但我还没有找到好的答案。我找到的答案通常是 "break your tests down into smaller units of work" 或 "use mocks"。我正在尝试遵循所有这些最佳实践。但是,即使使用模拟(我正在使用 Moq)并尝试将所有内容分解为最小的工作单元,我最终 运行 变成了一个具有多个输入、调用多个模拟服务并需要我的方法为那些模拟方法调用指定 return 值。

下面是被测代码示例:

public class Order
{
   public string CustomerId { get; set; }
   public string OrderNumber { get; set; }
   public List<OrderLine> Lines { get; set; }
   public decimal Value { get { /* return the order's calculated value */ } }

   public Order()
   {
      this.Lines = new List<OrderLine>();
   }
}

public class OrderLine
{
   public string ItemId { get; set; }
   public int QuantityOrdered { get; set; }
   public decimal UnitPrice { get; set; }
}

public class OrderManager
{
   private ICustomerService customerService;
   private IInventoryService inventoryService;

   public OrderManager(ICustomerService customerService, IInventoryService inventoryService)
   {
      // Guard clauses omitted to make example smaller
      this.customerService = customerService;
      this.inventoryService = inventoryService;
   }

   // This is the method being tested.  
   // Return false if this order's value is greater than the customer's credit limit.
   // Return false if there is insufficient inventory for any of the items on the order.
   // Return false if any of the items on the order on hold.
   public bool IsOrderShippable(Order order)
   {
      // Return false if the order's value is greater than the customer's credit limit
      decimal creditLimit = this.customerService.GetCreditLimit(order.CustomerId);
      if (creditLimit < order.Value)
      {
         return false;
      }

      // Return false if there is insufficient inventory for any of this order's items
      foreach (OrderLine orderLine in order.Lines)
      {
         if (orderLine.QuantityOrdered > this.inventoryService.GetInventoryQuantity(orderLine.ItemId)
         {
            return false;
         }
      }

      // Return false if any of the items on this order are on hold
      foreach (OrderLine orderLine in order.Lines)
      {
         if (this.inventoryService.IsItemOnHold(orderLine.ItemId))
         {
            return false;
         }
      }

      // If we are here, then the order is shippable
      return true;
   }
}

这是一个测试:

[TestClass]
public class OrderManagerTests
{
   [TestMethod]
   public void IsOrderShippable_OrderIsShippable_ShouldReturnTrue()
   {
      // Setup inventory on-hand quantities for this test
      Mock<IInventoryService> inventoryService = new Mock<IInventoryService>();
      inventoryService.Setup(e => e.GetInventoryQuantity("ITEM-1")).Returns(10);
      inventoryService.Setup(e => e.GetInventoryQuantity("ITEM-2")).Returns(20);
      inventoryService.Setup(e => e.GetInventoryQuantity("ITEM-3")).Returns(30);

      // Configure each item to be not on hold
      inventoryService.Setup(e => e.IsItemOnHold("ITEM-1")).Returns(false);
      inventoryService.Setup(e => e.IsItemOnHold("ITEM-2")).Returns(false);
      inventoryService.Setup(e => e.IsItemOnHold("ITEM-3")).Returns(false);

      // Setup the customer's credit limit
      Mock<ICustomerService> customerService = new Mock<ICustomerService>();
      customerService.Setup(e => e.GetCreditLimit("CUSTOMER-1")).Returns(1000m);

      // Create the order being tested
      Order order = new Order { CustomerId = "CUSTOMER-1" };
      order.Lines.Add(new OrderLine { ItemId = "ITEM-1", QuantityOrdered = 10, UnitPrice = 1.00m });
      order.Lines.Add(new OrderLine { ItemId = "ITEM-2", QuantityOrdered = 20, UnitPrice = 2.00m });
      order.Lines.Add(new OrderLine { ItemId = "ITEM-3", QuantityOrdered = 30, UnitPrice = 3.00m });

      OrderManager orderManager = new OrderManager(
         customerService: customerService.Object,
         inventoryService: inventoryService.Object);
      bool isShippable = orderManager.IsOrderShippable(order);

      Assert.IsTrue(isShippable);
   }
}

这是一个简化的例子。我正在测试的实际方法在结构上是相似的,但它们通常有更多的服务方法正在调用,或者它们有更多的模型设置代码(例如,Order 对象需要更多为测试工作而分配的属性)。

考虑到我的一些方法必须像这个例子一样同时做几件事(比如按钮点击事件背后的方法),这是为这些方法编写单元测试的最佳方式吗?

您已经走在正确的道路上。在某些时候,如果 'method under test' 很大(不复杂),那么您的单元测试必然很大(不复杂)。我倾向于区分 'big' 和 'complex' 的代码。复杂的代码片段需要简化..大代码片段有时更清晰但更简单..

在你的例子中,你的代码只是很大,并不复杂。因此,如果您的单元测试也很大,这没什么大不了的。

话虽如此,我们可以通过以下方式使其更清晰易读。

选项#1

测试的目标代码好像是:

public bool IsOrderShippable(订单订单)

正如我所见,至少有 4 个单元测试场景:

   // Scenario 1: Return false if the order's value is 
   // greater than the customer's credit limit

   [TestMethod]
   public void IsOrderShippable_OrderValueGreaterThanCustomerCreditLimit_ShouldReturnFalse()
   {
      // Setup the customer's credit limit
      var customerService = new Mock<ICustomerService>();
      customerService.Setup(e => e.GetCreditLimit(It.IsAny<string>())).Returns(1000m);

      // Create the order with value greater than credit limit
      var order = new Order { Value = 1001m };

      var orderManager = new OrderManager(
         customerService: customerService.Object,
         inventoryService: new Mock<IInventoryService>().Object);

      bool isShippable = orderManager.IsOrderShippable(order);

      Assert.IsFalse(isShippable);
   }

如您所见,这个测试非常紧凑。它不需要设置很多你不希望你的场景代码命中的模拟等。

类似地,您也可以为其他 2 个场景编写紧凑的测试..

最后,对于最后一个场景,您进行了正确的单元测试。 我唯一要做的就是提取一些私有辅助方法,使实际的单元测试非常清晰易读,如下所示:

   [TestMethod]
   public void IsOrderShippable_OrderIsShippable_ShouldReturnTrue()
   {
      // you can parametrize this helper method as needed
      var inventoryService = GetMockInventoryServiceWithItemsNotOnHold();

      // You can parametrize this helper method with credit line, etc.
      var customerService = GetMockCustomerService(1000m);

      // parametrize this method with number of items and total price etc.
      Order order = GetTestOrderWithItems();

      OrderManager orderManager = new OrderManager(
         customerService: customerService.Object,
         inventoryService: inventoryService.Object);

      bool isShippable = orderManager.IsOrderShippable(order);

      Assert.IsTrue(isShippable);
   }

如您所见,通过使用辅助方法,您使测试更小、更清晰,但我们确实在设置参数方面失去了一些可读性。

但是,我倾向于把辅助方法名和参数名说得非常明确,所以通过阅读方法名和参数,reader就清楚正在安排什么样的数据。

大多数时候,幸福之路场景最终需要最大的设置代码,因为它们需要正确设置所有模拟以及所有相关项目、数量、价格等。在那些情况下,有时我更喜欢把所有TestSetup 方法上的设置代码.. 这样默认情况下它可用于每个测试方法。

好处是,测试可以立即获得良好的模拟值。 )

缺点是快乐路径场景通常是一个单元测试..但是将这些东西放在 testSetup 中将 运行 它用于每个单元测试,即使他们永远不需要它。

选项 #2

这是另一种方式..

您可以将 IsOrderShippable 方法分解为 4 个私有方法,每个方法执行 4 个场景。您可以将这些私有方法设为内部,然后进行单元测试,处理这些方法(internalsvisibleto 等)..它仍然有点笨拙,因为您将私有方法设为内部,而且您仍然需要对您的 public 方法,这让我们有点回到原来的问题。