.NET 单元测试:先花后省

已发表: 2022-03-11

在与利益相关者和客户讨论单元测试时,经常会出现很多混淆和疑问。 单元测试有时听起来就像使用牙线对孩子所做的那样, “我已经刷牙了,为什么还要这样做?”

对于那些认为他们的测试方法和用户验收测试足够强大的人来说,建议单元测试通常听起来像是不必要的费用。

但是单元测试是一个非常强大的工具,而且比你想象的要简单。 在本文中,我们将了解单元测试以及 DotNet 中可用的工具,例如Microsoft.VisualStudio.TestToolsMoq

我们将尝试构建一个简单的类库来计算斐波那契数列中的第 n 项。 为此,我们将要创建一个用于计算斐波那契数列的类,该类依赖于将数字相加的自定义数学类。 然后,我们可以使用 .NET 测试框架来确保我们的程序按预期运行。

什么是单元测试?

单元测试将程序分解成最小的代码位,通常是函数级的,并确保函数返回预期的值。 通过使用单元测试框架,单元测试成为一个单独的实体,然后可以在构建程序时对程序运行自动化测试。

 [TestClass] public class FibonacciTests { [TestMethod] //Check the first value we calculate public void Fibonacci_GetNthTerm_Input2_AssertResult1() { //Arrange int n = 2; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert Assert.AreEqual(result, 1); } }

使用 Arrange, Act, Assert 方法测试我们的数学库可以正确添加 2 + 2 的简单单元测试。

设置单元测试后,如果对代码进行了更改,以说明程序最初开发时未知的附加条件,例如,单元测试将显示所有案例是否都符合预期值由函数输出。

单元测试不是集成测试。 这不是端到端测试。 虽然这两种方法都是强大的方法,但它们应该与单元测试结合使用——而不是作为替代品。

单元测试的好处和目的

单元测试最难理解但最重要的好处是能够即时重新测试更改的代码。 它之所以如此难以理解,是因为很多开发人员对自己说: “我再也不会碰那个功能了”,或者“我完成后会重新测试它”。 利益相关者会思考, “如果那篇文章已经写好了,我为什么需要重新测试它?”

作为一个在开发领域的两边都有的人,我已经说过这两件事了。 我内心的开发者知道为什么我们必须重新测试它。

我们每天所做的改变会产生巨大的影响。 例如:

  • 您的开关是否正确考虑了您输入的新值?
  • 你知道你用了多少次那个开关吗?
  • 您是否正确考虑了不区分大小写的字符串比较?
  • 您是否适当地检查空值?
  • 是否按预期处理了抛出异常?

单元测试接受这些问题并将它们记录在代码和过程中,以确保始终回答这些问题。 可以在构建之前运行单元测试,以确保您没有引入新的错误。 因为单元测试被设计成原子的,所以它们运行得非常快,通常每次测试不到 10 毫秒。 即使在一个非常大的应用程序中,一个完整的测试套件也可以在一个小时内完成。 您的 UAT 流程可以匹配吗?

设置命名约定的示例,以便在要测试的类中轻松搜索类或方法。
除了第一次运行并包括设置时间的Fibonacci_GetNthTerm_Input2_AssertResult1之外,所有单元测试都在 5 毫秒内运行。 我在这里的命名约定是为了在我要测试的类中轻松搜索类或方法

不过,作为开发人员,也许这对您来说听起来像是更多的工作。 是的,您可以放心,您发布的代码是好的。 但是单元测试也让你有机会看到你的设计在哪里薄弱。 您是否正在为两段代码编写相同的单元测试? 他们应该在一段代码上吗?

让您的代码本身可进行单元测试是您改进设计的一种方式。 对于大多数从未进行单元测试的开发人员,或者在编码之前没有花太多时间考虑设计的开发人员,您可以通过为单元测试做好准备来意识到您的设计改进了多少。

您的代码单元可测试吗?

除了 DRY,我们还有其他考虑。

您的方法或功能是否试图做太多事情?

如果您需要编写运行时间比您预期更长的过于复杂的单元测试,您的方法可能过于复杂并且更适合作为多个方法。

你是否正确地利用了依赖注入?

如果您的测试方法需要另一个类或函数,我们将其称为依赖项。 在单元测试中,我们不关心依赖项在幕后做了什么。 就被测方法而言,它是一个黑匣子。 依赖项有自己的一组单元测试,这些单元测试将确定其行为是否正常工作。

作为测试人员,您希望模拟这种依赖关系并告诉它在特定实例中返回什么值。 这将使您更好地控制测试用例。 为此,您需要注入该依赖项的虚拟(或我们稍后将看到的模拟)版本。

您的组件是否以您期望的方式相互交互?

一旦你解决了你的依赖和依赖注入,你可能会发现你已经在你的代码中引入了循环依赖。 如果 A 类依赖于 B 类,而 B 类又依赖于 A 类,那么您应该重新考虑您的设计。

依赖注入之美

让我们考虑一下我们的斐波那契示例。 你的老板告诉你他们有一个新类,它比 C# 中当前可用的 add 运算符更高效、更准确。

虽然这个特定示例在现实世界中不太可能出现,但我们确实在其他组件中看到了类似的示例,例如身份验证、对象映射以及几乎任何算法过程。 出于本文的目的,让我们假设您的客户的新添加功能是计算机发明以来最新最好的。

因此,您的老板给您一个黑盒库,其中包含一个类Math ,并且在该类中,有一个函数Add 。 你实现斐波那契计算器的工作可能看起来像这样:

 public int GetNthTerm(int n) { Math math = new Math(); int nMinusTwoTerm = 1; int nMinusOneTerm = 1; int newTerm = 0; for (int i = 2; i < n; i++) { newTerm = math.Add(nMinusOneTerm, nMinusTwoTerm); nMinusTwoTerm = nMinusOneTerm; nMinusOneTerm = newTerm; } return newTerm; }

这并不可怕。 您实例化一个新的Math类并使用它来添加前两个术语以获得下一个。 您通过正常的一系列测试运行此方法,计算出 100 个术语,计算第 1000 个术语,第 10,000 个术语,依此类推,直到您对您的方法正常工作感到满意为止。 然后在未来的某个时候,用户抱怨第 501 项没有按预期工作。 您花了一个晚上查看您的代码,并试图找出为什么这个极端案例不起作用。 你开始怀疑最新最好的Math课并没有你老板想象的那么好。 但这是一个黑匣子,你无法真正证明这一点——你在内部陷入了僵局。

这里的问题是依赖Math没有注入到您的斐波那契计算器中。 因此,在您的测试中,您总是依赖Math中现有的、未经测试的和未知的结果来测试斐波那契。 如果Math有问题,那么 Fibonacci 将永远是错误的(没有为第 501 项编码特殊情况)。

纠正这个问题的想法是将Math类注入到您的 Fibonacci 计算器中。 但更好的是,为Math类创建一个定义公共方法(在我们的例子中为Add )的接口,并在我们的Math类上实现该接口。

 public interface IMath { int Add(int x, int y); } public class Math : IMath { public int Add(int x, int y) { //super secret implementation here } } }

我们可以将IMath接口注入到 Fibonacci 中,而不是将Math类注入到 Fibonacci 中。 这里的好处是我们可以定义我们自己的OurMath类,我们知道它是准确的,并针对它测试我们的计算器。 更好的是,使用 Moq 我们可以简单地定义Math.Add返回的内容。 我们可以定义一些总和,或者我们可以告诉Math.Add返回 x + y。

 private IMath _math; public Fibonacci(IMath math) { _math = math; }

将 IMath 接口注入到 Fibonacci 类中

 //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y);

使用 Moq 定义Math.Add返回的内容。

现在我们有了一个经过验证的方法(好吧,如果 + 运算符在 C# 中是错误的,我们会有更大的问题)添加两个数字的方法。 使用我们新的 Mocked IMath ,我们可以为我们的第 501 个学期编写一个单元测试,看看我们是否搞砸了我们的实现,或者自定义的Math类是否需要更多的工作。

不要让方法尝试做太多事情

这个例子也指向了一个方法做得太多的想法。 当然,加法是一个相当简单的操作,不需要从我们的GetNthTerm方法中抽象出它的功能。 但是如果操作稍微复杂一点呢? 可能不是添加,而是模型验证,调用工厂来获取要操作的对象,或者从存储库中收集额外的所需数据。

大多数开发人员会尝试坚持一种方法有一个目的的想法。 在单元测试中,我们试图坚持单元测试应该应用于原子方法的原则,并且通过在方法中引入过多的操作,我们使其无法测试。 我们经常会创建一个问题,我们必须编写如此多的测试才能正确测试我们的功能。

我们添加到方法中的每个参数都会根据参数的复杂性以指数方式增加我们必须编写的测试数量。 如果您在逻辑中添加布尔值,则需要将要编写的测试数量加倍,因为您现在需要检查真假案例以及当前测试。 在模型验证的情况下,我们的单元测试的复杂性会迅速增加。

将布尔值添加到逻辑时所需的增加测试的图表。

我们都对在方法中添加一些额外内容感到内疚。 但是这些更大、更复杂的方法需要太多的单元测试。 当您编写单元测试时,很快就会发现该方法试图做的太多了。 如果您觉得您正在尝试从输入参数测试太多可能的结果,请考虑您的方法需要分解为一系列较小的事实这一事实。

不要重复自己

我们最喜欢的编程租户之一。 这应该是相当直截了当的。 如果您发现自己不止一次编写相同的测试,那么您已经不止一次地引入了代码。 将工作重构为一个公共类,您尝试使用它的两个实例都可以访问它,这可能会使您受益。

有哪些单元测试工具可用?

DotNet 为我们提供了一个非常强大的开箱即用的单元测试平台。 使用它,您可以实现所谓的 Arrange、Act、Assert 方法。 你安排好你最初的考虑,用你的测试方法对这些条件采取行动,然后断言发生了什么事。 你可以断言任何东西,使这个工具更加强大。 您可以断言某个方法被调用了特定次数、该方法返回了特定值、引发了特定类型的异常,或者您能想到的任何其他内容。 对于那些寻求更高级框架的人来说,NUnit 及其 Java 对应的 JUnit 是可行的选择。

 [TestMethod] //Test To Verify Add Never Called on the First Term public void Fibonacci_GetNthTerm_Input0_AssertAddNeverCalled() { //Arrange int n = 0; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Never); }

测试我们的斐波那契方法是否通过抛出异常来处理负数。 单元测试可以验证是否引发了异常。

为了处理依赖注入,DotNet 平台上同时存在 Ninject 和 Unity。 两者之间几乎没有区别,问题在于您是想使用 Fluent Syntax 还是 XML Configuration 来管理配置。

为了模拟依赖关系,我推荐 Moq。 起订量可能很难让您动手,但要点是您创建依赖项的模拟版本。 然后,您告诉依赖项在特定条件下返回什么。 例如,如果您有一个名为Square(int x)的方法,它对整数进行平方运算,您可以告诉它当 x = 2 时返回 4。您还可以告诉它对于任何整数都返回 x^2。 或者您可以告诉它在 x = 2 时返回 5。为什么要执行最后一种情况? 如果测试角色下的方法是验证依赖项的答案,您可能希望强制返回无效答案以确保正确捕获错误。

 [TestMethod] //Test To Verify Add Called Three times on the fifth Term public void Fibonacci_GetNthTerm_Input4_AssertAddCalledThreeTimes() { //Arrange int n = 4; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Exactly(3)); }

使用 Moq 告诉模拟的IMath接口如何处理被测Add 您可以使用It.Is或使用It.IsInRange设置范围。

DotNet 的单元测试框架

微软单元测试框架

Microsoft 单元测试框架是 Microsoft 开箱即用的单元测试解决方案,包含在 Visual Studio 中。 因为它带有VS,所以它很好地集成了它。 当您开始一个项目时,Visual Studio 会询问您是否要在应用程序旁边创建一个单元测试库。

Microsoft 单元测试框架还附带了许多工具来帮助您更好地分析您的测试过程。 此外,由于它由 Microsoft 拥有和编写,因此它的存在具有一定的稳定性。

但是,在使用 Microsoft 工具时,您会得到它们给您的东西。 Microsoft 单元测试框架的集成可能很麻烦。

单元

使用 NUnit 对我来说最大的好处是参数化测试。 在我们上面的斐波那契示例中,我们可以输入许多测试用例并确保这些结果是正确的。 并且在我们的第 501 个问题的情况下,我们总是可以添加一个新的参数集来确保测试始终运行而无需新的测试方法。

NUnit 的主要缺点是将其集成到 Visual Studio 中。 它缺少 Microsoft 版本附带的花里胡哨,这意味着您需要下载自己的工具集。

xUnit.Net

xUnit 在 C# 中非常流行,因为它与现有的 .NET 生态系统完美集成。 Nuget 有许多可用的 xUnit 扩展。 它还与 Team Foundation Server 很好地集成,尽管我不确定有多少 .NET 开发人员仍然在各种 Git 实现上使用 TFS。

不利的一面是,许多用户抱怨 xUnit 的文档有点缺乏。 对于单元测试的新用户来说,这可能会引起巨大的头痛。 此外,xUnit 的可扩展性和适应性也使学习曲线比 NUnit 或 Microsoft 的单元测试框架更陡峭。

测试驱动设计/开发

测试驱动设计/开发 (TDD) 是一个更高级的主题,值得单独发表。 但是,我想提供一个介绍。

这个想法是从你的单元测试开始,告诉你的单元测试什么是正确的。 然后,您可以围绕这些测试编写代码。 理论上,这个概念听起来很简单,但在实践中,很难训练你的大脑向后思考应用程序。 但是这种方法有一个内置的好处,即不需要在事后编写单元测试。 这会减少重构、重写和类混淆。

近年来,TDD 一直是一个流行词,但采用速度很慢。 它的概念性质使利益相关者感到困惑,因此难以获得批准。 但作为开发人员,我鼓励您使用 TDD 方法编写一个小型应用程序以适应该过程。

为什么你不能有太多的单元测试

单元测试是开发人员可以使用的最强大的测试工具之一。 它不足以对您的应用程序进行全面测试,但它在回归测试、代码设计和目的文档方面的优势是无与伦比的。

没有写太多单元测试这样的事情。 每个边缘案例都可能在您的软件中提出大问题。 将发现的错误作为单元测试进行纪念可以确保这些错误在以后的代码更改期间不会找到方法重新进入您的软件。 虽然您可以在项目的前期预算中增加 10-20%,但您可以节省的远不止培训、错误修复和文档方面的费用。

您可以在此处找到本文中使用的 Bitbucket 存储库。