揭开极端编程的神秘面纱: 测试驱动的编程

Posted on 2005-06-15 11:09  数码民工  阅读(1066)  评论(0编辑  收藏  举报
通过在编写代码之前编写测试使一切简单

最近 50 年来,测试一直被视为项目结束时要做的事。当然,可以在项目进行之中结合测试,测试通常并不是在 所有编码工作结束后才开始,而是一般在稍后阶段进行测试。然而,XP 的提倡者建议完全逆转这个模型。作为一名程序员,应该在编写代码 之前编写测试,然后只编写足以让测试通过的代码即可。这样做将有助于使您的系统尽可能的简单。

先编写测试
XP 涉及两种测试: 程序员测试客户测试。测试驱动的编程(也称为 测试为先编程)最常指第一种测试,至少我使用这个术语时是这样。测试驱动的编程是让 程序员测试(即单元测试 ― 重申一下,只是换用一个术语)决定您所编写的代码。这意味着您必须在编写代码之前进行测试。测试指出您 需要编写的代码,从而也 决定了您要编写的代码。您只需编写足够通过测试的代码即可 ― 不用多,也不用少。XP 规则很简单:如果不进行程序员测试,则您不知道要编写什么代码,所以您不会去编写任何代码。

如何先编写测试
整个理论很棒,但如何 编写测试呢?首先,我推荐您阅读 Kent Beck 撰写的 Test-Driven Development: By Example(请参阅 参考资料)一书,里面列举了一个详尽的贯穿于整本书的示例。该书不仅讲述了如何编写测试和让这些测试来驱动您的代码的原理,而且还讲述了测试驱动的编程为什么是一种好的编程方法。这里我将举一个简单的例子,让您体会一下我正在讲什么。

测试驱动 vs. 先测试
我喜欢用“测试驱动”这个术语,而不喜欢用“先测试”这个术语,因为先测试强调了在编写代码前编写程序员测试这个原理。这些原理很重要,但真正的力量在于测试驱动所隐含的想法和编程习惯的改变。“测试驱动的编程”这一更贴切的术语包含两种测试,它指出 XP 团队强调让测试驱动他们所要做的一切这一方式。

假定我正在编写包含 Person 对象的系统。我希望在我问每个 Person 时,他/她能告诉我其年龄(作为整数)。即使我还没有编写一丁点代码,但也该编写测试了。“什么?”,您可能会说,“我甚至不知道在测试什么,怎么编写测试?”答案很简单,您 的确知道您在测试什么,只是不 知道您所了解的内容,因为您不习惯按这样的方式进行思考。这就是我的意思。

您确实还没有任何代码,但您脑海中应有 Person 对象的雏形。 Person 对象上应该有一个方法,该方法可以用整数形式返回年龄。因为我最常使用 Java 语言,所以我用 JUnit 来编写程序员测试。清单 1 显示了我为 Person 对象编写的 JUnit 测试:

清单 1. 用于 Person 对象的 JUnit 测试

package com.roywmiller.testexample;

import junit.framework.TestCase;

public class TC_Person extends TestCase {

	protected Person person;

	public TC_Person(String name) {
		super(name);
	}

	protected void setUp() throws Exception {
		person = new Person();
	}

	public void testGetAge() {
		int actual = person.getAge();
		assertEquals(0, actual);
	}

	protected void tearDown() throws Exception {
	}

}

首先,让我向那些不熟悉 JUnit 的人讲述一些浅显的原理。 TestCase 类是您将最常使用的类。您只是写了一个测试类(在该示例是 TC_Person ),它是 TestCase 的子类。(注:在 JUnit 3.8.1 中,可以有也可以没有接受 String 的构造函数,但由于我几乎所有的 Java 开发都在 Eclipse IDE(请参阅 参考资料)中完成,Eclipse IDE 免费向我提供了这个构造函数,所以我就把它保留在这里了。)一旦创建好测试类之后,测试方法中要有实际的动作。这些方法都恰如其分地用前缀 test 开头(它们必须是 public ,并且返回 void )。当运行测试时,JUnit:

  • 内省测试类,并执行每个以“test”开头的方法
  • 在执行每个测试方法之前执行 setUp() 方法
  • 在执行每个测试方法之后执行 tearDown() 方法

在该示例中, setUp() 方法中没有太多要执行的语句。它只是实例化 Person (我用这个方法是让您觉得这个测试案例看上去很“完整”)。这意味着,如果这里有 20 个测试方法,则每个测试方法都以一个新的 Person 实例开始。 tearDown() 中不做任何事情,所以现在它是空的。值得强调的一点,您不需要 setUp()tearDown() ;我通常直到编写第二个或第三个测试方法,并确定了这些方法都共享某些公共的设置或销毁活动时,才创建它们。

有了这些原理之后,要注意,我在测试方法中制订了一些设计决策。我假定,可以构造一个 person,并且“缺省” Person 会返回值为 0 的 age。还假定 Person 对象有 getAge() 方法。即使那些假定不会一直都成立,但目前它们还适用。可以说,这是一个简单的测试,让我说明测试驱动的编程。有了这些假定之后,实例化 Person (在 setUp() 中实例化 Person 只是为了展示如何使用 setUp() 方法),接着调用测试方法中正在测试的方法,然后调用其中一种“断言(assert)”方法。断言方法测试事情是否为 true。换句话说,这些方法针对某件事做出一个断言,该断言告诉 JUnit 验证该事是否为 true。表 1 列出了断言的类别:

表 1. 断言类别

断言方法 描述
assertEquals 比较两件事物是否相等(基本类型或对象)
assertTrue 对布尔值求值,看它是否为 true
assertFalse 对布尔值求值,看它是否为 false
assertNull 检查对象是否为 null
assertNotNull 检查对象是否不为 null
assertSame 检查两个对象是否为同一实例
assertNotSame 检查两个对象是否不为同一实例

在这里,我检查 Person 实例的 age 是否为 0,新 Person 对象的缺省值为 0。

当然,这个测试甚至不能编译。 

显然,我还没有 Person 类,所以运行该测试会出现问题 — JUnit 给出了一个红条。如果可以运行,并通过测试,则会显示一个绿条。您的目标总是设法得到一个绿条。别忘了,JUnit 的座佑铭是“得到绿条,使代码干净”(有时抱怨是难免的)。

没问题。我将创建 Person 类,如清单 2 所示:

清单 2. Person 类

package com.roywmiller.testexample;

public class Person {
	
	public int getAge() {
		return 0;
	}

}

现在,当运行这个测试时,测试通过,应该可以看到一个绿条。我必须从 getAge() 返回值,否则不会编译它。这里碰巧 0 最方便,0 被认为是新的 Person 实例的缺省值,所以工作正常。再次重申,我只编写了通过测试所需的代码。

能够使 Person 具有缺省的年龄值固然很好,但这对我的系统不会有太大帮助。 Person 需要比这更智能些。我真正所需要的是, Person 拥有其生日,并能回答其当前的年龄。这意味着 Person 对象的年龄会随时间的推移而增长。在进行编码前,将 testGetAge 重命名为 testGetDefaultAge (清楚地表明,我正在测试缺省的年龄),并为这个测试案例编写另一个测试方法,如清单 3 所示:

清单 3. 新的测试方法

public void testGetAge() {
	GregorianCalendar calendar = new GregorianCalendar(1971, 3, 23);
	person.setBirthDate(calendar.getTime());
	int actual = person.getAge();
	assertEquals(31, actual);
}

还不能编译这个测试(您注意到了其中的模式吗?),因为 Person 内没有 setBirthDate() 方法。在创建了这个方法之后, Person 将类似于清单 4 所示:

清单 4. 更新的 Person 类

package com.roywmiller.testexample;

import java.util.Date;

public class Person {
	
	protected Date birthdate;
	
	public int getAge() {
		return 0;
	}
	
	public void setBirthDate(Date aBirthDate) {
		this.birthdate = aBirthDate;
	}

}

Person 中的 getAge() 仍然没有什么变化,所以测试失败。
 

生成的 AssertionFailedError 告诉我结果不是 31 而是 0。这个失败在预料之中,因为我没有改变 getAge() 方法来做某些不同的事。现在仅仅编写足够使测试通过的代码(这里有两个测试)。我必须允许年龄的缺省值为 0,但我必须计算出生于 1971 年 3 月 23 日的人的年龄。一些程序员(包括 Kent Beck)建议在这一点上尽可能简单,譬如检查 birthdate ,看它是否为 null ― 如果为 null,则返回 0,否则返回 31 ― 然后编写另一个测试使计算更智能。一小步一小步地思考问题这种方法是很好的技术,我们要采用这种技术,当您想回到上面提到的基本规程来使自己摆脱调试惯例时,那是再好不过。但这里我想使该示例略微简单些,所以我仅仅试图通过按我所希望的方式,用 Calendar 计算年龄,使该测试通过。清单 5 显示了 Person 中我所编写的代码:

清单 5. getAge() 实现

package com.roywmiller.testexample;

import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;

public class Person {
	
	protected Date birthdate;
	
	public int getAge() {
		if (birthdate == null)
			return 0;
		else {
			int yearToday = 
			  Calendar.getInstance().get(Calendar.YEAR);
			
			Calendar calendar = new GregorianCalendar();
			calendar.setTime(birthdate);
			int birthYear =calendar.get(Calendar.YEAR);
			
			return yearToday - birthYear; 
		}
	}
	
	public void setBirthDate(Date aBirthDate) {
		this.birthdate = aBirthDate;
	}

}

当我运行测试时,我失败了,预期的结果为 31,但实际结果为 32。怎么了?唔,我知道问题一定出在刚才所写的代码中,没有进一步考虑下去。在检查完 else 子句之后,我明白我只是根据年来计算年龄。这不对。我现在 31 岁,但这个月再过几天我要 32 岁了(但我写该代码时,是 3 月份),我的算法造成错误的结果。所以需要重新考虑 getAge() 。我用清单 6 中的代码段纠正了这个错误:

清单 6. 改正后的 getAge()

else {
	int yearToday = Calendar.getInstance().get(Calendar.YEAR);
	
	Calendar calendar = new GregorianCalendar();
	calendar.setTime(birthdate);
	int birthYear = calendar.get(Calendar.YEAR);
	
	if (yearToday == birthYear)
		return yearToday - birthYear;
	else
		return yearToday - birthYear - 1;
}

绿条!在 Person 类中有一些重复的代码,但我把它留给稍后的重构练习。欢迎替我清理该代码。您可以有信心地做这件事,因为可以运行测试来证实您没有破坏任何事物。

这个示例使您体会到了测试驱动的编程类似于什么。我只在每步编写足够让测试通过的代码。作为一种理论,这在思想倾向上是一种挑战。您必须习惯这种思想,在编写代码 之前,可以并应该编写测试。在通过所有测试之后,就完成了工作。

在先编写测试时,必须习惯故意只看眼前。清单 6 中的示例是一个十分简单的情形。即使最简单的编程问题,在实际当中通常要更复杂。这种方法有助于将问题分解成更可管理的部分,但您最终仍可能遇到一些复杂的令人头疼的问题。在那些情况下,必须使自己不要考虑太远,不要假定它的“普适性”有多高,也不要假定这种方法能处理某些尚未遇到的情形。仅仅编写测试,使它通过。您需要采取一些较小的步骤,然后编写迫使您要采取更多步骤的测试。请记住,您正在测试代码的存在性,如果您以较小的步骤来编写代码,那您就做对了。

为什么应该先编写测试……
也许您不认为先编写测试是一个好主意。它看上去似乎很奇怪,或者也许似乎没有必要。我常从富有经验的程序员那听到第二种原因。这些程序员很聪明,他们具有许多经验,他们说不需要先编写测试,因为他们知道自己在做什么。我能领会,但我怀疑他们存在一个隐式的假定:他们没能领会先编写测试。恕我难以苟同。事实上,我认为采用先编写测试方法有三个原因:

  • 学习
  • 新问题
  • 信心

先编写测试 ― 到后面再执行这些测试 ― 是较佳的学习方式。它使您能将精力集中在所编写代码的接口部分。在编写测试时,您假设正在使用的类已经存在,然后按照您希望在系统其余部分中使用的方式来使用该类。稍后,当您忘记如何使用该类时,可以查看测试,看一个非常具体的示例。这是学习的很好方式。

关于先编写测试最有趣的事情之一是,它有助于发现新问题。您正在使创建之中的系统“成长”起来。如果您正在使用 XP,则没有预先设计整个事情 ― 而是一边开发一边设计。在您先编写测试并通过测试这个过程中,您正在让代码告诉您它想要做什么,以及会成为什么。如果仅仅着手编码工作,则您完全按照您的设想来行事了。越晚做决定,则越有可能发现新问题和新动向,这些可使您的系统更完善。

但是,我所喜欢的先编写测试的好处是让这些测试在稍后执行。在我先编写测试时,我有许多奇异的逻辑。我可能没有涵盖代码的方方面面,但会包括其中许多方面。在任何情况下,我会有一套测试,这套测试比我曾参与过的大多数没采用 XP 的项目要更好。我可以按一个按钮就运行这些测试。几秒种之后,我就知道代码是否按我告诉它应该怎样的方式来运行。这种可回归的工具是很有价值的。我团队中的任何人(或任何地方的任何人)可以在任何时候更改代码,甚至在代码发布的前一天也可以更改,因为如果有任何问题,测试会立即告诉他们。作为一名程序员,这给予了我信心 ― 比大多数程序员具有更大的信心。

帮助形成“揭开极端编程的神秘面纱”的未来
一如既往,我热忱邀请您就以后的专栏文章提出您的反馈意见,这样有助于促进这个专栏。关于 XP,您存在的最大问题是什么?您认为是完全愚蠢的、不明智的、非专业的还是不可能的?最让您感到迷惑的做法是什么?请在本文的 论坛提出您的建议,或者就直接 给我发电子邮件

……为什么人们没有采用
许多没有先编写测试的程序员甚至不知道还可以使用这种方法。如果他们知道,也可能对如何使用它感到迷惑,或者他们可能想知道为什么要这样做。即使他们知道如何去做,并认为它是一种好的想法,但许多人仍然没有先编写测试。

先编写测试需要遵循一定的规程。作为一名程序员,我认为,对于我正在开发的工作,不编写测试可能会更容易些。有时确实如此,但通常只会在短期内是这样。如果我经常不写测试,那么不久会有一堆代码没有经过测试。当编写下一个系统功能部件时,可能会出现不正常现象,问题出在哪里?没有测试,我无法胸有成竹地回答这个问题。即使一切似乎都工作良好,但我不能确保过去在系统中没有出现的问题在以后还不会出现。这种恶性循环就是为什么大多数程序员讨厌测试人员告诉他们代码出现问题的原因。在没有测试的前提下,跟踪错误造成了加班加点以及对工作的不满意。

在我用那种方式向大多数程序员说明这种情况时,他们认为测试驱动的编程是一个不错的想法 ― 这之后,他们仍然不使用这种方法。在编写代码前编写测试这种作法意味着,在测试运行并失败之前,不会做工作中真正有趣的部分。不要掉入这个陷阱,否则您以后会付出很多。

难以处理的情形
在人们开始编写测试时,总是会遇到这样一些情形:他们说,“只是没有办法进行测试”。XP 社区的一些人可能毫不含糊地说,不写测试就永远别写代码。您应该努力尝试这么做,但以我个人的经验,有时我发现有些地方我也不能这么做。如果您发现自己处在这种情形,您应该放弃吗?在一定程度上可以。我认为您可以做两件事:

  • 在编写测试前编写代码
  • 在很少情况下,根本不编写测试,放到后面编写

如果发现在尝试先编写测试之后,仍不能先编写测试,那么回到测试中来。我仍然希望进行测试,这样我可以从完整的回归套件中获得信心,但我必须先编写一些代码,然后编写测试。有时我编写了一点代码,然后编写一点测试,这样两者可以一起并进。在少数情况下,我恰好根本想不出如何编写测试。在出现这种情形并且我的结对搭档也想不出法子时,我就问问其他人(例如,另一对搭档),看看他们是否什么聪明点的主意。有时这很管用。但还有一些时候,整个团队都陷入了困境。在那些情况下,必须选择可行性。我可能暂停编码,陷入困境,或者在没有测试的情形下,编写一些代码,到稍后再编写测试。也许代码中出现的第一个错误会使测试什么以及如何测试变得更为明晰。这些是可行的规则。

测试工具和技术
在这世上,几乎每种语言都有一个 xUnit 库。对于 Java 平台,则由 JUnit 担当此任。我个人使用 Eclipse IDE(请参阅 参考资料),它极好地集成了 JUnit。Eclipse 是开放源码,有它自己的测试套件,您可以使用它。使用这个合适工具,您可以编写大量好的测试。但有时最好有一些其它帮助。幸运的是,可以利用一些编码技术来更方便地进行测试,甚至可以测试 看上去不可测试的事物。可以使用的一些技术包括 ObjectMother模式、 模仿对象(Mock Object)伪(Sham)对象。

ObjectMother 模式
ObjectMother 模式实际是 Gang of Four Abstract Factory 模式(请参阅 参考资料)的实现,它告诉您创建一个工厂对象来给出需要测试对象的实例。例如,假定您正在构建一个处理客户预订讲座的系统。您可能构建一个 ObjectMother 对象,使 Seminar 对象具有不同种特征,您可以用这些特征来测试某些情况。在 Java 语言中,您可能创建 TF_Seminar 对象,它有几个静态工厂方法 ― 也许称为 createSomethingnewSomething 。用您对正在创建事物的一些描述代替“something”,譬如 newFullyLoaded ,用它来创建具有所有数据成员、并且这些数据成员都已填有已知数据的 Seminar 。这样做使得测试数据放在一个地方,从而使代码更干净,更容易重构。在代码中,每当需要完全装入的 Seminar 来进行测试时,可以象清单 7 那样做:

清单 7. ObjectMother 示例

Seminar seminar = TF_Seminar.newFullyLoaded;
seminar.doSomething();
assertEquals("expectedValue", seminar.getValue());

模仿对象
模仿对象使您可以为测试而模仿对象(请参阅 参考资料)。给模仿对象一个接口,您希望实际组件具备这个接口,然后使用模仿对象,直到实际组件成形。但模仿对象不仅仅只是还未存在的组件的存根。可以评估代码如何与模仿对象交互(譬如,验证调用了某个方法多少次以及检查状态等)。最近模仿对象得到了大力推广,但我认为它们被滥用了,它们太“重”以至于不切实际。

伪对象
有时我所希望的是一个伪对象,它实现了与真实对象相同的接口,可以回答关于我在测试中如何与它交互这样一些特定问题。这就是伪对象 ― 一种用来伪装测试中对象的轻量型方式。伪对象可以是您所需要的任何对象。它是我曾使用过的最全面灵活的工具、模式和思考方式,我推荐您使用它。例如,我在前面创建的 Person 对象的伪对象类似于清单 8 中的样子:

清单 8. Person 的伪对象

protected class ShamPerson extends Person {
	protected boolean getAgeWasCalled;
	
	public int getAge() {
		getAgeWasCalled = true;
		return 25;
	}
}

如果可行,我总是试图采用正在测试类的伪类(在这里是 ShamPerson )作为测试中的内部类。通过这样做,从而向不必需要伪类的其它事物隐藏了该伪类。

一旦有了伪对象,我就可以在不直接测试 Person ,而是测试其它代码如何与 Person 实例进行交互的测试中用到它。我可以实例化 ShamPerson ,然后与它交互,然后断言 getAgeWasCalled 为 true。

编程革命
在编写代码前编写测试极大地改变了我作为程序员的生活,它同样也可以改变您的生活。我的代码始终比先编写测试之前所写的代码更简单、更干净以及更健壮。只要记住这条规程 ― 在编写代码之前考虑如何测试代码 ― 就可以使代码变得更好。如果每个软件开发团队不采用其它 XP 做法,并且只是先编写测试,则软件开发世界将会令人惊异地变得更好。采用这一点做法,任何程序员都可以先编写测试。这些工具(JUnit 和 Eclipse 等)是免费的,只等您去实践它。我已经看到投资得到了及时的回报,我相信您也会这样做的。

Copyright © 2024 数码民工
Powered by .NET 9.0 on Kubernetes