在过去的五年左右的时间里,单元测试的实践方式经历了显著的变化。最初,遵循的是单元测试教程中推崇的模式:
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)……
这种方法仍然存在一些问题。主要是,对于这里展示的示例来说,它过于复杂了。初始化器和断言并没有复杂到需要自己的类,而且意图的可读性不足以证明额外的工作量是合理的。