在开发可测试软件的过程中,单元测试已成为确保软件质量的一个不可或缺部分。测试驱动开发(Test-Driven Development,TDD)是编写单元测试的一种方法,采用该方法的开发人员在编写任何产品代码之前都需要编写测试程序。TDD 允许开发人员以系统的方式完善软件设计,从而有效的提高单元测试的质量,增加回归测试(指修改代码后的再次测试)带来的好处。
单元测试和测试驱动开发的意义
当谈到软件测试时,通常是指进行的一系列不同种类的测试,包括单元测试、验收测试(acceptance testing)、探索测试(exploratory testing)、性能测试(performance testing)、可扩展性测试(scalability testing)等。
大部分的单元测试通常具有以下 4 个特点:
- 测试小部分产品代码(单元)
编写单元测试时,我们经常查找能够合理测试的最小功能片段。在像 C# 这样的面向对象编程语言中,类通常就意味着是最小的功能片段,但大多数情况下,我们测试的是类中的一个方法。源代码的阅读次数远超过编写次数,这点在单元测试中特别有用,因为单元测试要测试软件的期望规则和行为。当单元测试失败时,开发人员应该能够快速的阅读测试程序,理解什么出错了,为什么会出错,从而能够快速的修正出错的地方。使用小的测试程序来测试小片段代码能够极大的改善测试结果的可理解性。
- 产品代码分块隔离测试
单元测试另一个重要方面是它能够在问题出现时精确的指出问题出现的位置。把测试的代码与和它有交互的复杂代码隔离,以确保出现的故障一定是在测试代码中,而不是在与其交互的代码中(检查交互的合作代码是否存在 bug 是合作代码单元测试的任务)。
- 只测试公共端点
在修改类的内部实现时,有时,对代码的一点点修改就可能会导致多个单元测试的失败。因此,在修改产品代码时,维护这些单元测试的人员通常会感到很沮丧。之所以会这样,是因为单元测试对它要测试的类的工作原理了解太多。当编写单元测试时,如果仅局限于产品的公共端(一个组件的集成点),就可以将单元测试与组件的许多内部实现细节相隔离,这样,修改实现细节就不会经常破坏已经编写好的单元测试了。
- 运行测试程序能够得到自动的结果:pass / fail
如果对每一小段代码编写测试程序,显而易见,最终我们会编写很多单元测试。如果测试过程不是自动的,这将会损耗开发人员的大部分精力。为了获得自动过程,开发人员通常使用单元测试框架。框架允许开发人员使用自己擅长的编程语言和开发环境编写测试程序,然后创建 pass / fail 规则集,之后框架会判定测试是否成功。单元测试框架中通常有一个称为运行程序(runner)的小软件,可用来在项目中查找和执行单元测试。有很多这样的软件,一些集成到了 VS 中,一些集成到了 GUI 中,一些需要命令行运行等。
测试驱动开发的定义(TDD)
测试驱动开发指的是利用单元测试来驱动产品代码设计的过程。首先编写单元测试,然后编写产品代码使其通过测试。当把单元测试作为质量保障机制时,主要指的是减少软件中的漏洞。TDD 可以实现这一目标,但这不是它的主要目标;TDD 的主要目标是提高软件设计的质量。通过首先编写单元测试,我们可以在编写任何产品代码之前描述想要组件执行的操作。由于还没有产品代码的详细实现,因此,我们不会把精力放到产品代码的任何具体实现上,单元测试变成了产品代码的消费者。
- 红 / 绿 周期
仍然遵循前面为单元测试设置的指导原则:编写小段代码、隔离测试和自动执行测试。由于首先编写测试程序,因此当使用 TDD 时,经常会进入一个周期步骤:
- 编写一个单元测试
- 运行单元测试,得到 fail 结果(因为尚未编写产品代码)
- 编写足够的产品代码,通过但愿测试
- 运行单元测试程序,得到 pass 结果
重复以上步骤,直到产品代码编写完毕为止。由于大部分的单元测试框架用红色的 文本 / UI 元素表示失败,用绿色表示通过,因此,这个周期又成为 红 / 绿 周期。
- 重构
“重构”一词具有多种意义,这里是指在不改变产品代码外部可见功能的情况下,修改产品代码实现细节的过程。在重构和更新产品代码的过程中,单元测试应该能够继续通过。重构时不需要修改任何单元测试程序;如果要求必须修改单元测试,则要按照“红 / 绿 周期”的步骤来添加、删除或改变,切勿同时修改测试程序和产品代码。更确切的说,重构是一种机制,在不破坏单元测试程序的情况下,构建结构化代码的过程。
- 采用 Arrange(准备测试环境)、Act(在测试中调用的方法)、Assert(确保按预期执行) 结构化测试
本文中许多例子都遵照一个成为“Arrange、Act、Assert ”的结构(3A),单元测试的代码如下所示:
[TestMethod]
public void PoppingReturnsLastPushedItemFromStack()
{
// Arrange
Stack<string> stack = new Stack<string>();
string value = "Hello World!";
stack.Push(value);
// Act
string result = stack.Pop();
// Assert
Assert.AreEqual(value, result);
}
arrange 部分创建了一个空栈并推进一个值,这是测试功能的先决条件;act 部分从栈中弹出 arrange 部分添加的值;最后,assert 部分测试一个合乎逻辑的行为:从栈中弹出的值和推进栈中的值是否一样。本例中,assert 部分只有一行代码,难道没有许多其他可以断言(编程术语,表示布尔表达式)的行为吗?例如,一旦从栈中弹出推进的值,栈就变空;难道我们不应该确保它是空的吗?如果此时再尝试弹出另一个值,程序就会异常;难道我们不也应该编写程序测试吗?
在一个测试中,一定不要同时测试多个行为。一个好的单元测试程序通常只测试一个非常小的功能,即一个单一行为。本例测试的是一个非空栈弹出的已知行为,而不是一个空栈的所有属性,否则,应编写更多单元测试。保持测试程序的精简和单一意味着当修改产品代码时只需要修改很少的地方,如果把若干个行为混到一个单元测试(或跨过多个单元测试)中,一个单一行为的破坏可能会导致数十个测试程序的失败,我们将不得不在每个测试程序中过滤这几个行为以确定出现故障的行为。
一些开发人员将这一规则称为 单一断言规则(single assertion rule)。不要误以为测试程序只能调用一次 Assert,其实,只要记得一次只测试一个行为,而验证一个合乎逻辑的行为调用多次 Assert 是经常的,有必要的。
创建单元测试项目
尽管可以在 VS 中直接创建单元测试项目,但是开始对 ASP.NET MVC 应用程序进行单元测试需要做大量繁琐的工作。因此,ASP.NET MVC 团队在 New Project 对话框中为 ASP.NET MVC 应用程序包含了单元测试功能:
如果这样,VS 会用一套默认的单元测试来填充新创建的项目,这些默认的单元测试可以帮助新用户理解如何编写 ASP.NET MVC 应用程序的测试程序。
当创建新项目时,系统会自动打开 HomeController.cs 文件,其中包含 3 个操作方法,下面是 Index 操作方法的源代码:
public ActionResult Index()
{
ViewBag.Message = "Modify this template to jump-start your ASP.NET MVC application.";
return View();
}
非常简单,在 ViewBag 中设置欢迎文本并发送给视图,然后返回一个视图结果。在默认的单元测试项目中,Index 操作方法只有一个测试程序:
[TestMethod]
public void Index()
{
// Arrange
HomeController controller = new HomeController();
// Act
ViewResult result = controller.Index() as ViewResult;
// Assert
Assert.AreEqual("Modify this template to jump-start your ASP.NET MVC application.", result.ViewBag.Message);
}
上面是一个非常好的单元测试:按照 3A 形式编写,3 行代码非常容易理解。但尽管这样,该单元测试程序仍然有待完善。虽然 Index 操作方法只有 2 行源代码,却要完成 3 项任务:
- 把欢迎文本设置到 ViewBag 对象中
- 返回一个视图结果
- 返回的视图结果使用默认视图
但这里的默认单元测试实际上是在测试这 3 个问题中的 2个,且存在一个潜在的微妙错误。由于我们让单元测试尽可能精简、单一集中,因此这里至少需要 2 个单元测试,一个用于测试欢迎文本,另一个用于测试返回的视图结果。当然,如果编写 3 个单元测试,也不为错。
微妙的错误出现在 as 关键字的使用,as 的转换如果与给定的内容不兼容,就会返回 null,而在单元测试程序的 assert 部分,在并没有检查返回的视图结果是否为空的情况下,就解引用了 result。这里将该问题标记为待测试的第 4 个问题:操作方法不能返回 null。
as 转换真的是必需的吗?单元测试程序需要 ViewResult 类的一个实例才能访问 ViewBag 属性,这部分没有问题。但我们对操作方法略作修改:
public ViewResult Index()
{
ViewBag.Message = "Modify this template to jump-start your ASP.NET MVC application.";
return View();
}
修改了返回类型后,也可以清楚的表达代码的功能:Index 总是返回一个视图。这样的一个微小修改,测试的 4 个问题减少为 3 个问题。接下来把前面的测试程序重写成两个:
[TestMethod]
public void IndexShouldAskForDefaultView()
{
HomeController controller = new HomeController();
ViewResult result = controller.Index();
Assert.IsNotNull(result);
Assert.IsNull(result.ViewName);
}
[TestMethod]
public void IndexShouldSetWelcomeMessageInViewBag()
{
// Arrange
HomeController controller = new HomeController();
// Act
ViewResult result = controller.Index();
// Assert
Assert.AreEqual("Modify this template to jump-start your ASP.NET MVC application.", result.ViewBag.Message);
}
这样修改后,看起来测试程序就好多了。as 的转换被省略,更加详细、描述性更强的方法名称使我们在不查看测试程序的内部代码的情况下,就可以理解测试失败的原因。我们可能不知道名为 Index 的测试程序为什么会失败,但我们一定知道名为 IndexShouldSetWelcomeMessageInViewBag 测试失败的原因。
新的 2 个测试方法有重复的代码,如果进行重构,之后的代码会如下:
private HomeController controller;
private ViewResult result;
[TestInitialize]//标识在测试之前要运行的方法,从而分配并配置测试类中的所有测试所需的资源
public void SetupContext()
{
controller = new HomeController();
result = controller.Index();
}
[TestMethod]
public void IndexShouldAskForDefaultView()
{
Assert.IsNotNull(result);
Assert.IsNull(result.ViewName);
}
[TestMethod]
public void IndexShouldSetWelcomeMessageInViewBag()
{
Assert.AreEqual("Modify this template to jump-start your ASP.NET MVC application.", result.ViewBag.Message);
}
从好的方面来说,这样减少了代码的重复。但不好的是,移动了测试方法中的 arrange 部分和 act 部分。如果打算以这种方式使用单元测试,那么每个上下文使用一个测试类最好;另外,当一个单一测试类中添加了数十(或数百)个测试时,支持所有这些测试的必要设置代码就会变得非常多。此时,就不能清楚地知道哪个单元测试需要哪些设置代码了。