具有复杂设置和逻辑的单元测试
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 方法,这让我们有点回到原来的问题。
我正努力在单元测试方面变得更好,我最大的不确定性之一是为需要大量设置代码的方法编写单元测试,但我还没有找到好的答案。我找到的答案通常是 "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 方法,这让我们有点回到原来的问题。