单元测试的演进与最佳实践

在过去的五年左右的时间里,单元测试的实践方式经历了显著的变化。最初,遵循的是单元测试教程中推崇的模式:

public class CalculatorFixture { [Test] public void CanAddTwoPositiveNumbers() { var calculator = new Calculator(); int result = calculator.Add(13, 45); Assert.AreEqual(58, result); } [ExpectedException(typeof(OverflowException))] [Test] public void OverflowCausesException() { var calculator = new Calculator(); calculator.Add(int.MaxValue, 1); } }

这种模式简单明了,但编写起来耗时且不利于代码重用。虽然可以将初始化代码移动到标记为[SetUp]的方法中,但这会在每个测试用例中运行,可能并不总是合适的。

因此,将测试重构为更正式的Arrange, Act, Assert模式。忘记了是从哪里学到这个模式的,但就像许多事情一样,短暂评估了一下,然后决定采用它。

[TestFixture] public abstract class TestBase { [SetUp] public void Init() { GivenThat(); When(); } protected virtual void GivenThat() { } protected abstract void When(); }

这个模式仍然相当简单,但更具表现力。它允许以如下方式指定测试:

[TestFixture] public abstract class WhenUsingTheCalculator : TestBase { protected abstract override void GivenThat() { base.GivenThat(); this.calculator = new Calculator(); } private Calculator calculator; } public abstract class WhenAddingTwoNumbers : WhenUsingTheCalculator { protected override void When() { this.result = this.calculator.Add(X, Y); } protected abstract int X { get; } protected abstract int Y { get; } protected int result; } public class WhenAddingTwoPositiveNumbers : WhenAddingTwoNumbers { protected override int X { get { return 13; } } protected override int Y { get { return 45; } } [Test] public void ItShouldReturnTheCorrectResult() { Assert.AreEqual(58, this.result); } } public class WhenAddingNumbersThatCauseOverflow : WhenAddingTwoNumbers { protected override int X { get { return int.MaxValue; } } protected override int Y { get { return 1; } } [Test] public void ItShouldThrowAnOverflowException() { } }

这种风格并没有太大改进,老实说。当然,当测试失败时,它会生成一个不错的输出(例如:"WhenAddingNumbersThatCauseOverflow.ItShouldThrowAnOverflowException() failed")。但请注意,后者的测试不会工作。需要更多的工作来允许捕获预期的异常(提示:异常是在When()中抛出的,而[Test]方法是在这之后运行的)。

尽管如此,还是坚持使用这个增强版本一段时间,因为重用似乎值得这种抽象的漏洞。但事实是,重用是通过继承实现的,这最终导致了一些问题,因为任何在最初编写测试之后来到测试的人都会对复杂的、深层的继承体系感到困惑。有时这个人就是……

最近,决定重新审视一下,试图找到满足以下要求的东西:

  • 支持初始化代码和断言的重用
  • 更多地依赖组合而不是继承来创建测试
  • 对于第一次看到测试的人来说非常容易理解(测试是一种可靠的文档形式)

结果如下:

首先,快速解释一下。单元测试有非常简单的行为:Arrange()设置前提条件,Act()对目标对象进行操作,Assert()验证后置条件。这可能是测试运行器需要了解的所有单元测试的内容(如果需要的话,它可能只需要运行单元测试,但通过将它们分解成组成部分,可以尝试/捕获仅在Assert()周围,例如寻找AssertionException)。

在数据方面,单元测试由许多初始化器、许多断言和一个操作组成。注意,这些组件并不专属于单个单元测试实例,而是在它们之间共享,从而促进重用。

实现这个模型很快就展示了如何在短时间内偏离文档。将跳过发布和解释实现(但会提供一个链接,当对结果满意时,代码将在那里)。相反,将分享真正重要的东西:它对客户端的外观。

在CalculatorTests项目下,有一个名为Initializers.cs的文件(用于所有Calculator前提条件),一个名为Assertions.cs的文件(用于所有Calculator断言),然后是一个名为CalculatorTests.cs的文件,其中包含所有与Calculator类相关的单元测试。注意,这是人们需要查看的唯一文件,以了解代码的意图。

public class CalculatorIsDefaultConstructed : IInitializer { public void Prepare(ref ICalculator target) { target = new Calculator(); } } public class ResultShouldEqual : IAssertion { private int expectedValue; public ResultShouldEqual(int expectedValue) { this.expectedValue = expectedValue; } public void Verify(ICalculator target, int returnValue) { Assert.IsNotNull(target); Assert.AreEqual(this.expectedValue, returnValue); } } public class ExceptionThrown : IAssertion where TException : Exception { public void Verify(Exception exceptionThrown) { Assert.IsInstanceOf(typeof(TException), exceptionThrown); } }

单元测试现在看起来像这样:

public class CanAddTwoPositiveNumbers : UnitTest { public CanAddTwoPositiveNumbers() { GivenThat(); When(calculator => calculator.Add(13, 45)); Then(58); } } public class OverflowCausesException : UnitTest { public OverflowCausesException() { GivenThat(); When(calculator => calculator.Add(int.MaxValue, 1)); ThenThrow(); } }

因此,每个单元测试的Arrange、Act、Assert组件都清晰可见,并且命名为良好的类,但每个组件的实现都隐藏在其他地方。可以推断出CalculatorIsDefaultConstructed的作用,所以不需要看到它的内部。测试的几乎英语语言规范也很不错。将为初始化和断言注册添加一个流畅的接口,包括And()。现在,这在所有意图和目的上,都是行为驱动开发(BDD)……

这种方法仍然存在一些问题。主要是,对于这里展示的示例来说,它过于复杂了。初始化器和断言并没有复杂到需要自己的类,而且意图的可读性不足以证明额外的工作量是合理的。

沪ICP备2024098111号-1
上海秋旦网络科技中心:上海市奉贤区金大公路8218号1幢 联系电话:17898875485