使用 NUnit 参数化测试使您的单元测试 DRY(不重复)





5.00/5 (3投票s)
使用 NUnit 的参数化测试属性减少单元测试中的重复代码。通过这个简单的重构练习学习如何做到这一点。
引言
单元测试就像支付火灾保险。没有保险是愚蠢的,但你不必喜欢为此付费。因此,你会货比三家以获得好的交易,因为你不想花费不必要的钱。
单元测试的成本往往比应有的要高。最常见的问题是重复代码。许多单元测试夹具包含彼此之间非常相似的方法。这违反了 DRY 原则,但如果没有正确的工具,我们无能为力。我们不得不忍受重复代码带来的维护和可读性的噩梦。
那么,我们该如何让我们的测试摆脱重复呢?
幸运的是,许多单元测试框架确实提供了我们所需的工具来优化我们的单元测试。NUnit 也不例外。在这篇文章中,我们将对一个包含四个几乎相同的测试方法的测试夹具应用一系列重构。
(注意:以下示例使用 NUnit 3。撰写本文时,很少有第三方测试运行器能很好地支持 NUnit 3,因此我们重构后的单元测试会失败。NUnit 2.x 支持这些示例中使用的所有功能,但使用略有不同的语法,必要时会在下面注明。)
使用代码
请参考链接的 Visual Studio 2013 解决方案进行操作。您需要使用 nuget 添加 NUnit。
每个迭代都在 "OrderValidatorFixture.cs" 的子夹具中。
湿代码测试
让我们来看看一个虚构的 "OrderValidator
" 类的单元测试。"OrderValidator
" 会查看购物车的内容并评估它们的有效性。在本演示中,我们将特别关注一个属性:"HasOverlappingDiscounts
"。
"HasOverlappingDiscounts
" 在这两种条件都为 "true
" 时应为 "true
":订单在购物车中有促销商品并且应用了优惠券。在所有其他情况下,它应为 false。这只是简单的 AND 逻辑,因此我们有以下四个单元测试来覆盖所有可能的输入。
[Test]
public void HasOverlappingDiscountsIsFalseIfNoCouponsAndNoSaleItemsTest()
{
var factory = new OrderValidatorFactory
{
CouponProvider = GetCouponProviderStub(hasAppliedCoupons: false),
SaleItemsProvider = GetSaleItemsProviderStub(hasSaleItems: false)
};
var sut = factory.GetOrderValidator();
Assert.IsFalse(sut.HasOverlappingDiscounts);
}
[Test]
public void HasOverlappingDiscountsIsFalseIfHasCouponsAndNoSaleItemsTest()
{
var factory = new OrderValidatorFactory
{
CouponProvider = GetCouponProviderStub(hasAppliedCoupons: true),
SaleItemsProvider = GetSaleItemsProviderStub(hasSaleItems: false)
};
var sut = factory.GetOrderValidator();
Assert.IsFalse(sut.HasOverlappingDiscounts);
}
[Test]
public void HasOverlappingDiscountsIsFalseIfNoCouponsAndHasSaleItemsTest()
{
var factory = new OrderValidatorFactory
{
CouponProvider = GetCouponProviderStub(hasAppliedCoupons: false),
SaleItemsProvider = GetSaleItemsProviderStub(hasSaleItems: true)
};
var sut = factory.GetOrderValidator();
Assert.IsFalse(sut.HasOverlappingDiscounts);
}
[Test]
public void HasOverlappingDiscountsIsTrueIfHasCouponsAndHasSaleItemsTest()
{
var factory = new OrderValidatorFactory
{
CouponProvider = GetCouponProviderStub(hasAppliedCoupons: true),
SaleItemsProvider = GetSaleItemsProviderStub(hasSaleItems: true)
};
var sut = factory.GetOrderValidator();
Assert.IsTrue(sut.HasOverlappingDiscounts);
}
正如您所见,存在大量重复。每个测试的设置都非常相似。此外,前三种方法的断言是相同的。我们已经引入了辅助方法来创建我们的存根,但测试仍然非常相似。一定还有什么办法可以减少重复代码的量。幸运的是,NUnit 具有参数化测试的概念。
参数化测试的第一步
参数化测试只是通过方法参数而不是在方法本身中硬编码值来将值传递给测试方法。
让我们重构我们的第一个测试以利用参数化测试。我们可以应用 "TestCase
" 属性并传入两组数据。
[TestCase(false, false)] // attribute changed
public void HasOverlappingDiscountsIsFalseIfNoCouponsAndNoSaleItemsTest(bool hasAppliedCoupons, bool hasSaleItems) // parameters added
{
var factory = new OrderValidatorFactory
{
CouponProvider = GetCouponProviderStub(hasAppliedCoupons: hasAppliedCoupons), // use parameter
SaleItemsProvider = GetSaleItemsProviderStub(hasSaleItems: hasSaleItems) // use parameter
};
var sut = factory.GetOrderValidator();
Assert.IsFalse(sut.HasOverlappingDiscounts);
}
这里发生了什么?"TestCase
" 中的两个参数将成为传递给测试方法参数的值。如果我们运行这些测试,测试运行器将运行此测试,并将 "false
" 传递给 "hasAppliedCoupons
" 和 "hasSaleItems
"。因此,尽管重构后的测试看起来不同,但它在功能上等同于原始测试。
如果我们重新运行测试,它们仍然全部通过。
请注意,"Test
" 属性现在是多余的,因此我们已将其删除。
到目前为止,我们还没有减少任何重复,但我们即将为我们的努力获得一些回报。
每个方法有多个测试用例
事实证明,您可以将多个 "TestCase
" 属性添加到单个单元测试中。通过添加另外两个具有适当值的 "TestCase
" 属性,我们可以增加此测试提供的覆盖范围。
[TestCase(false, false)]
[TestCase(true, false)] // new
[TestCase(false, true)] // new
public void HasOverlappingDiscountsIsFalseIfNoCouponsAndNoSaleItemsTest(bool hasAppliedCoupons, bool hasSaleItems)
{
var factory = new OrderValidatorFactory
{
CouponProvider = GetCouponProviderStub(hasAppliedCoupons: hasAppliedCoupons),
SaleItemsProvider = GetSaleItemsProviderStub(hasSaleItems: hasSaleItems)
};
var sut = factory.GetOrderValidator();
Assert.IsFalse(sut.HasOverlappingDiscounts);
}
现在,测试运行器将执行此测试方法 3 次,一次将两个测试参数都设置为 "false
",一次仅将第一个参数设置为 "true
",一次仅将第二个参数设置为 "true
"。在每次执行中,它仍然断言 "HasOverlappingDiscounts
" 的值为 "false
",这是正确的行为。
果然,所有测试仍然通过。到目前为止,一切顺利。
由于我们的第一个测试现在测试的是相同的逻辑,因此我们可以删除测试二和测试三。
如何处理积极测试?
这很好,但如果能消除对第四个测试的需求就更好了。剩余的两个测试仍然包含重复代码。为此,让我们首先将第四个测试重构为与第一个测试相同的格式。
[TestCase(true, true)] // attribute changed
public void HasOverlappingDiscountsIsTrueIfHasCouponsAndHasSaleItemsTest(bool hasAppliedCoupons, bool hasSaleItems) // parameters added
{
var factory = new OrderValidatorFactory
{
CouponProvider = GetCouponProviderStub(hasAppliedCoupons: hasAppliedCoupons), // use parameter
SaleItemsProvider = GetSaleItemsProviderStub(hasSaleItems: hasSaleItems) // use parameter
};
var sut = factory.GetOrderValidator();
Assert.IsTrue(sut.HasOverlappingDiscounts);
}
在此重构步骤中,我们没有删除任何重复。但是,如果您将其与第一个测试进行比较,除了 "TestCase
" 属性中的值之外,只有一个区别;在此测试中,我们断言 "HasOverlappingDiscounts
" 的值为 "true
"。
那么,我们如何消除剩余的重复呢?
合并测试的一种方法
根据我们目前所知,显而易见的做法是向我们的方法添加第三个布尔参数,称为 "expected
"。然后,我们不再有一个使用 "Assert.IsFalse
" 和一个使用 "Assert.IsTrue
" 的测试,而是可以有一个使用 "Assert.AreEqual
" 的单一测试。
[TestCase(false, false, false)] // Third parameter added to each TestCase
[TestCase(true, false, false)]
[TestCase(false, true, false)]
[TestCase(true, true, true)]
public void HasOverlappingDiscountsIsFalseIfNoCouponsAndNoSaleItemsTest(bool hasAppliedCoupons, bool hasSaleItems, bool expected) // Third parameter added to method
{
var factory = new OrderValidatorFactory
{
CouponProvider = GetCouponProviderStub(hasAppliedCoupons: hasAppliedCoupons),
SaleItemsProvider = GetSaleItemsProviderStub(hasSaleItems: hasSaleItems)
};
var sut = factory.GetOrderValidator();
Assert.AreEqual(expected, sut.HasOverlappingDiscounts); // Compare to new parameter
}
这将正确工作,但有一种稍微更具可读性的表达方式。
预期结果
方便的是,"TestCase
" 属性接受一个命名参数 "ExpectedResult
"(或 NUnit 2.x 中的 "Result
")。
此属性告诉测试运行器测试方法的预期结果。我们不必在方法内显式断言,只需返回我们要测试的值。测试运行器将为我们进行断言。
从功能上讲,使用 "ExpectedResult
" 等同于将第三个参数传递给断言。但它带来的好处是提高了可读性。它清楚地向其他开发人员传达了测试方法的预期结果。
因此,让我们看看它会是什么样子,并将我们剩余的两个测试方法重构为利用 `ExpectedResult
`。
[TestCase(false, false, ExpectedResult = false)] // ExpectedResult added to each TestCase
[TestCase(true, false, ExpectedResult = false)]
[TestCase(false, true, ExpectedResult = false)]
public bool HasOverlappingDiscountsIsFalseIfNoCouponsAndNoSaleItemsTest(bool hasAppliedCoupons, bool hasSaleItems) // return value changed
{
var factory = new OrderValidatorFactory
{
CouponProvider = GetCouponProviderStub(hasAppliedCoupons: hasAppliedCoupons),
SaleItemsProvider = GetSaleItemsProviderStub(hasSaleItems: hasSaleItems)
};
var sut = factory.GetOrderValidator();
return sut.HasOverlappingDiscounts; // return result
}
[TestCase(true, true, ExpectedResult = true)] // ExpectedResult added to TestCase
public bool HasOverlappingDiscountsIsTrueIfHasCouponsAndHasSaleItemsTest(bool hasAppliedCoupons, bool hasSaleItems) // return value changed
{
var factory = new OrderValidatorFactory
{
CouponProvider = GetCouponProviderStub(hasAppliedCoupons: hasAppliedCoupons),
SaleItemsProvider = GetSaleItemsProviderStub(hasSaleItems: hasSaleItems)
};
var sut = factory.GetOrderValidator();
return sut.HasOverlappingDiscounts; // return result
}
如果我们运行这些测试,它们都会通过。
看看这两个测试方法。它们现在是相同的,除了它们各自的 "TestCase
" 属性。
然后只剩一个
由于我们可以为每个测试拥有多个 "TestCase
" 属性,我们可以将第四个测试的测试用例移到第一个测试,然后删除第四个测试方法。
同时,我们将重命名剩余的测试,以更准确地反映我们正在测试的内容。
[TestCase(false, false, ExpectedResult = false)]
[TestCase(true, false, ExpectedResult = false)]
[TestCase(false, true, ExpectedResult = false)]
[TestCase(true, true, ExpectedResult = true)] // Test case added
public bool HasOverlappingDiscountsIsTrueOnlyIfHasAppliedCouponsAndHasSaleItemsTest(bool hasAppliedCoupons, bool hasSaleItems) // Method renamed
{
var factory = new OrderValidatorFactory
{
CouponProvider = GetCouponProviderStub(hasAppliedCoupons: hasAppliedCoupons),
SaleItemsProvider = GetSaleItemsProviderStub(hasSaleItems: hasSaleItems)
};
var sut = factory.GetOrderValidator();
return sut.HasOverlappingDiscounts;
}
通过应用 "TestCase
" 属性,我们能够重构掉所有重复的代码。我们从四个方法减少到一个。
与原始的四个单元测试相比,在可读性方面。测试名称仍然描述了方法的行为,但它是在单个测试名称中而不是跨四个测试名称。此外,所有输入及其预期结果都显示在测试上方。任何查看此方法的开发人员都可以一目了然地准确了解方法的行为。
在可维护性和可读性方面,此重构是明显的胜利。
显然,并非所有单元测试都如此简单,但您可以使用类似的技术将大多数测试组织成更具可读性的格式,并且重复性更少。如果有兴趣,我们可以在以后的文章中探讨其中一些。
祝您在使单元测试符合 DRY 原则的道路上一帆风顺。
首次发布:使用 NUnit 参数化测试实现单元测试的 DRY - Ready to Rock Software Development