我们将从构建简单的驼鹿监视软件开始示例各种场景下的测试驱动开发。
鹿是鹿家族中的最大个的成员。目前世界上估计有2百万头。为了更好的统计,世界驼鹿组织让我们开发一个驼鹿监视软件,希望保护人员能够跟踪和记录他们见到的驼鹿。
本文假设读者已经熟悉java/ant/maven/classpath,而且至少写过一两个简单的junit测试。
Vanilla JUnit
首先编写Moose类:
package moose;
import java.util.Date;
public class Moose {
private Date dateTimeObserved;
private int age;
public Moose(Date dateTimeObservedParam, int estimatedAge) {
this.dateTimeObserved = dateTimeObservedParam;
this.age = estimatedAge;
}
}
虽然驼鹿最长可以生存25年,但大部分驼鹿在年青的时候就被捕杀,通常只活了2、3年。根据世界驼鹿组织报告,我们想知道某一年龄后的驼鹿数据,因此我们增加了isOlderThan(int)方法。
单元测试如下:
public void testIsOlderThan() {
Moose moose = new Moose(new Date(), MOOSE_AGE);
assertTrue("Moose age "+ MOOSE_AGE +" should of been older than "+ TEST_AGE, moose.isOlderThan(TEST_AGE) );
}
方法:
boolean isOlderThan(int contenderAge) {
return this.age >= contenderAge;
}
这是一个普通的junit测试用例,这种方式通常用在测试驱动的开发中。这种完全独立的方法一般是很少的;通常我们都需要一些代价昂贵的或比较难构建的外部资源。
使用伪对象和jMock
观察驼鹿的最佳时间是在黎明和黄昏,跟其他动物的观察时间相似。WOM希望我们提供获取记录驼鹿被发现的时间的保护人员,这样就可以通过String getObserverName()来获取相应的驼鹿了。
不幸地是,保护人员的接口来自第三方接口PersonnelUnit,而这是个大家伙没有LDAP我们是无法构造的。
为了测试getObserverName(),我们可以启动LDAP服务器,发送数据,运行测试用例,然后再关闭LDAP。但那将是集成测试,而不是单元测试了,对我们来说宁可多写些代码。因此,我们创建了一个包含我们需要方法的Ranger接口:
public interface Ranger {
String getName();
}
这给我们第一个启发式的单元测试:用接口分离外部依赖。
我们需要改变Moose的构造函数增加Ranger参数。于是变成:
... above as before...
private Ranger observer;
public Moose( Date dateTimeObservedParam,
int estimatedAge,
Ranger observedBy)
{
this.dateTimeObserved = dateTimeObservedParam;
this.age = estimatedAge;
this.observer = observedBy;
}
... below as before ...
后面,我们会实现Ranger接口作为包含在最终产品中的PersonnelUnit代理。但现在,我们需要的是一个简单实现返回我们所需要名字的实现—记住我们是在测试Moose而不是Ranger.
这种我们通过硬编码或定义方法调用异常的简单实现方式被称为伪对象。伪对象是测试中经常需要的东西,目前有很多可用的类库供你选择。其中最好用的类库之一是jMock。他使用J2SE1.3中的动态代理使我们在运行时创建接口实现。
为了使用jMock,我们必须首先修改我们的测试类使用权其继承MockObjectTestCase,并且在setup()和teardown()中调用父类中的相应方法:
public class TestMoose extends MockObjectTestCase {
public void setUp() throws Exception {
super.setUp();
}
public void tearDown() throws Exception {
super.tearDown();
}
... the rest as before ...
使用jMock后,getObserverName()的测试就更简单了:
public void testObserverName() {
Mock rangerMock = mock(Ranger.class);
rangerMock.expects( once() ).method("getName").will( returnValue(RANGER_NAME) );
Moose moose = new Moose(new Date(), MOOSE_AGE, (Ranger) rangerMock.proxy() );
assertEquals("Moose did not report correct ranger", RANGER_NAME, moose.getObserverName() );
}
让我们逐行地解释一下:
·Mock rangerMock = mock(Ranger.class);
这行创建了一个Mock对象来伪装Ranger接口的实现,调用rangerMock.proxy()返回Ranger
·rangerMock.expects( once() ).method("getName").will( returnValue(RANGER_NAME) );
这里是最有趣的地方。他的用途很明显:告诉Mock期望getName()方法仅被调用一次,并且在被调用后应该返回RANGER_NAME的值。在测试的最后,当我们的tearDown()方法调用super.tearDown()时,我们的父类MockObjectTestCase会检查是否所有期望值都已经满足,否则就会失败。
· Moose moose = new Moose(new Date(), MOOSE_AGE, (Ranger) rangerMock.proxy() );
这行创建Moose对象。注意我们是如何获取Ranger的实现的,通过调用的Mock.proxy()方法。
·assertEquals("Moose did not report correct ranger", RANGER_NAME, moose.getObserverName() );
最后是测试本身。很容易吧。因为Ranger接口使用起来很方便,我们并不需要经常这样。
注册模式
驼鹿喜欢居住吃水池草,但他们也是别人的猎物。因为驼鹿与别的物种的关系,WOM要求我们在发现一只驼鹿的时候给其他的团队发送消息。这些消息将被熊/狼/鹿/水池草的保护人员获取。
在项目中的消息系统是一个企业级的服务系统,如Tibco Rendezvous, IBM MQSeries, 或JMS的实现。就像PersonnelUnit依赖LDAP服务器,这种消息系统的要求也使得测试变的困难。如何才能使这些对象在没有后台的服务系统时存在呢?我们又如何能得到一个这样的消息系统呢?开始的想法是在集成测试中处理,但实际上我们需要的是一个接口和相应的模式。
让我们用一个友好的接口来隐藏消息系统吧:
public interface Messenger {
void sendMessage(String topic, Object[] values);
}
现在是我们第二个启发式的单元测试:用接口标示服务或角色,因此他们的名字通常心or/er为结尾。
现在,我们需要的是如何获取一个Messenger的实现,这相对于寻找驼鹿来说简单多了。如果你使用IOC容器(如Spring或HiveMind),你已经知道容器会帮你处理Messenger实现。否则,使用注册模式(服务定位模式的一种),这是一种简单的全局静态图来映射服务名与其实现。
public class Registry {
private static Map registry = new HashMap();
public static void put(String key, Object implementation) {
registry.put(key, implementation);
}
public static Object get(String key) {
return registry.get(key);
}
}
在代码中,我们这样使用注册:
Messenger messenger = (Messenger) Registry.get("MESSENGER");
messenger.sendMessage(A_TOPIC, someValues);
跟着是测试用例:
public void testMessageIsSent() {
Date observationDate = new Date();
Object[] valueArray = new Object[] { observationDate, new Integer(MOOSE_AGE) };
Mock messenger = mock(Messenger.class);
messenger.expects( once() ).method("sendMessage").with( eq(MESSAGE_TOPIC), eq(valueArray) );
Registry.put( "MESSENGER", messenger.proxy() );
Moose moose = new Moose(observationDate, MOOSE_AGE, null );
}
让我们来看一下重要的几行:
· Mock messenger = mock(Messenger.class);
创建一个实现Messenger接口的伪对象。在产品代码中,我们会创建一个与MS, Rendezvous或类似需求通讯的实现。
·messenger.expects( once() ).method("sendMessage").with( eq(MESSAGE_TOPIC), eq(valueArray) );
告诉伪对象期望sendMessage()方法只被调用一次,包含两个值:消息主题和一组消息内容。
·Registry.put( "MESSENGER", messenger.proxy() );
注册接口的伪实现。在这里,所有获取来自注册表中Messenger的代码会得到我们刚才创建的伪对象。
·Moose moose = new Moose(observationDate, MOOSE_AGE, null);
最后是我们的测试。我知道这看起来不像是个测试,但在他运行后,会运行tearDown()和调用super.tearDown()。在那个方法中会检查是否所有期望被满足,否则测试失败。因此如果sendMessage()方法没有调用我们的伪消息器,消息就会失败。
数据库测试
我知道你可能会想:“与LDAP和消息通讯的确不错,但在实际中,我们通常需要与数据库通讯。”是的,就像我们第一个启发式测试中(通过接口分离外部依赖),让我们简单地用接口来隐藏数据库:
public interface StorageManager {
void save(Object objectToSave) throws StorageException;
}
将实现放在注册表中,而代码只需要这样写:
StorageManager storageManager = (StorageManager) Registry.get( Registry.STORAGE );
storageManager.save( myObject );
在这里我们会用StorageException来封装所有实现中的异常。
不管用Hibernat,JDO或者其他持久层实现,都很容易将你与JDBC分离开来。我知道为每一个创建的对象编写select/insert/update/delete是很无聊的。因此我们可以只写一个HibernateStorageManager来实现StorageManager并处理其他细节。(如果你必须手写JDBC,Mockrunner项目可能对你写单元测试有所帮助)
在我们的单元测试中,会创建一个StorageManager伪对象并期望save方法被正确的对象调用。下面的save()的测试方法:
public void testSave() throws StorageException {
// Create a mock Messenger than ignores any messages it gets
Mock messenger = mock(Messenger.class);
messenger.stubs().method("sendMessage");
Registry.put( Registry.MESSENGER, messenger.proxy() );
// Create the moose
Moose moose = new Moose(new Date(), MOOSE_AGE, null);
// Create a mock StorageManager and tell it what will happen when we save the moose
Mock storage = mock(StorageManager.class);
storage.expects( once() ).method("save").with( same(moose) );
Registry.put(Registry.STORAGE, storage.proxy());
// Test !
moose.save();
}
让我们来看一下重要部分。首先我们建立一个Messenger桩:
messenger.stubs().method("sendMessage");
Jmock拥有一次或多次激活的桩。如果桩没有被调用或者被多次调用测试也不会失败。从先前的部分我们知道如何创建一个Moose并通过Messenger接口来发送消息。这个行为与save()方法无关,我们不希望他影响我们的测试,因此创建一个桩。
下一步,我们创建测试的Moose对象并在伪对象StorageManager上设置我们的期望值:
storage.expects( once() ).method("save").with( same(moose) );
这个看起来很直观。我们期望save()方法被调用一次,而moose对象被作为参数。在内部,same()方法用==来比较对象。而前面使用的eq()方法会使用equals。
最后我们保存的Moose:
moose.save();
一旦测试完成,Junit会运行tearDown()方法来检查所有的期望值是否被满足,否则测试失败。这个测试确保我们在请求Moose保存自己的时候,他会将工作代理给StorageManager。
当我们实际的StorageManager实现时(如HibernateStorageManager),我们会编写集成测试来确保他正确工作。如果两到三个集成测试用例保证HibernateStorageManager正确工作,那么你所需要在你单元测试中检查的只胡对象需要正确地将保存工作代理给StorageManager。我们测试的保存并不会真正地保存。
小结
所有上面的测试用例可以从资源中下载,可阅读旁注“构建样例程序”来运行样例。
下面是我在本文中所关注的两个重点:
1、使用接口将你的代码与外部资源分离,就像母牛保护她的孩子。
2、使用jMock创建这些接口的伪实现。
这两个技巧会的用处就像单元测试的用处。就像驼鹿不能出汗因为他们庞大的身体而且热量会通过内脏的发酵过程发散出去,我们在测试中也不需要出汗。