超越他人
VMware 提供培训和认证,助您加速前进。
了解更多前几天我在客户那里,他们问了我关于单元测试和模拟对象的问题。我决定将我们讨论的一些内容写成一篇关于为单元测试创建依赖(协作者)的教程。我们讨论了两种方案:存根和模拟对象,并给出了一些简单的例子来**阐明**它们的用法以及两种方法的优缺点。
在单元测试中,模拟或存根被测类的协作者是很常见的,这样测试就独立于协作者的实现。这样做也很有用,可以精确控制测试中使用的数据,并验证单元的行为是否符合预期。
存根方法易于使用,并且单元测试无需额外的依赖。其基本技术是将协作者实现为具体类,这些类仅展示被测类所需的一小部分协作者整体行为。例如,考虑正在测试的服务实现。该实现有一个协作者
public class SimpleService implements Service {
private Collaborator collaborator;
public void setCollaborator(Collaborator collaborator) {
this.collaborator = collaborator;
}
// part of Service interface
public boolean isActive() {
return collaborator.isActive();
}
}
为了测试 isActive 的实现,我们可以有一个这样的单元测试
public void testActiveWhenCollaboratorIsActive() throws Exception {
Service service = new SimpleService();
service.setCollaborator(new StubCollaborator());
assertTrue(service.isActive());
}
...
class StubCollaborator implements Collaborator {
public boolean isActive() {
return true;
}
}
存根协作者除了返回我们测试所需的值之外,什么也不做。
常见的是将此类存根以内联方式实现为匿名内部类,例如
public void testActiveWhenCollaboratorIsActive() throws Exception {
Service service = new SimpleService();
service.setCollaborator(new Collaborator() {
public boolean isActive() {
return true;
}
});
assertTrue(service.isActive());
}
这节省了我们大量维护单独声明的存根类的时间,也有助于避免存根实现的常见陷阱:在单元测试中重复使用存根,以及项目中具体存根数量的激增。
这有什么问题?通常,像这样的服务中的协作者接口不像这个简单示例这样简单,内联实现存根需要几十行未在服务中使用的方法的空声明。此外,如果协作者接口发生变化(例如,添加一个方法),我们必须手动更改所有测试用例中的所有内联存根实现,这可能是一项繁重的工作。
为了解决这两个问题,我们从一个基类开始,不再为每个测试用例重新实现接口,而是扩展一个基类。如果接口发生变化,我们只需要修改基类。通常,基类会存储在项目中的单元测试目录下,而不是生产或主源代码目录下。
例如,这里是为定义的接口准备的合适基类
public class StubCollaboratorAdapter implements Collaborator {
public boolean isActive() {
return false;
}
}
这是新的测试用例
public void testActiveWhenCollaboratorIsActive() throws Exception {
Service service = new SimpleService();
service.setCollaborator(new StubCollaboratorAdapter() {
public boolean isActive() {
return true;
}
});
assertTrue(service.isActive());
}
现在,测试用例不受影响 isActive 方法的协作者接口变化的影响。实际上,使用 IDE,它也能不受影响 isActive 方法的接口变化的影响——例如,IDE 可以自动更改所有测试用例中的名称或签名。
内联存根方法非常有用且实现快速,但为了对测试用例有更多的控制权,并确保如果服务对象的实现发生变化,测试用例也相应变化,模拟对象方法更好。
使用模拟对象(例如来自 EasyMock 或 JMock)我们可以对被测单元实现的内部进行高度控制。
为了在实践中看到这一点,考虑上面使用 EasyMock 重写的例子。首先我们看 EasyMock 1(即不利用 EasyMock 2 中 Java 5 的扩展)。测试用例将如下所示
MockControl control = MockControl.createControl(Collaborator.class);
Collaborator collaborator = (Collaborator) control.getMock();
control.expectAndReturn(collaborator.isActive(), true);
control.replay();
service.setCollaborator(collaborator);
assertTrue(service.isActive());
control.verify();
如果实现更改为以不同方式使用协作者,则单元测试将立即失败,通知开发人员需要重写。假设服务的内部更改为根本不使用协作者
public class SimpleService implements Service {
...
public boolean isActive() {
return calculateActive();
}
}
上述使用 EasyMock 的测试将失败,并显示明确的消息,说明在协作者上预期的方法调用未执行。在存根实现中,测试可能失败也可能不失败:如果失败,错误消息将是隐晦的;如果不失败,那也只是偶然的。
为了修复失败的测试,我们必须修改它以反映服务的内部实现。不断修改测试用例以反映实现的内部结构,有些人认为这是一种负担,但实际上,这样做是单元测试的本质。我们测试的是单元的实现,而不是它与系统其余部分的契约。要测试契约,我们会使用集成测试,并将服务视为一个黑盒,由其接口而非实现定义。
请注意,如果我们使用 Java 5 和 EasyMock 2,上述测试用例实现可以简化
Collaborator collaborator = EasyMock.createMock(Collaborator.class);
EasyMock.expect(collaborator.isActive()).andReturn(true);
EasyMock.replay(collaborator);
service.setCollaborator(collaborator);
assertTrue(service.isActive());
EasyMock.verify(collaborator);
在新的测试用例中不再需要 MockControl。如果只有一个协作者,比如这里,这没什么大不了的,但如果有很多协作者,那么测试用例将显著更容易编写和阅读。
如果模拟对象更优越,为什么我们还要使用存根呢?这个问题很可能会把我们带入“宗教战争”的领域,我们暂时会小心避免。所以简单的答案是,“选择适合你的测试用例,并创建最易读和维护的代码”。如果使用存根的测试编写和阅读都很快速,并且你不太担心协作者的变化,或者在被测单元内部使用协作者的方式,那么这样做是可以的。如果协作者不在你的控制之下(例如来自第三方库),那么编写存根可能往往更困难。
一个常见的情况是,当被测单元需要在协作者上进行嵌套方法调用时,存根比模拟对象更容易实现(和阅读)。例如,考虑如果我们更改服务,使其不再直接使用协作者的 isActive,而是嵌套调用另一个协作者(例如一个不同类别的 Task),会发生什么
public class SimpleService implements Service {
public boolean isActive() {
return !collaborator.getTask().isActive();
}
}
使用 EasyMock 2 中的模拟对象测试这个
Collaborator collaborator = EasyMock.createMock(Collaborator.class);
Task task = EasyMock.createMock(Task.class);
EasyMock.expect(collaborator.getTask()).andReturn(task);
EasyMock.expect(task.isActive()).andReturn(true);
EasyMock.replay(collaborator, task);
service.setCollaborator(collaborator);
assertTrue(service.isActive());
EasyMock.verify(collaborator, task);
同一个测试的存根实现将是
Service service = new SimpleService();
service.setCollaborator(new StubCollaboratorAdapter() {
public Task getTask() {
return (new StubTaskAdapter() {
public boolean isActive() {
return true;
}
}
}
});
assertTrue(service.isActive());
在长度方面,两者之间没有太大区别(忽略适配器基类中的代码,这些代码可以在其他测试中重用)。模拟版本更健壮(出于上面讨论的原因),所以我们更喜欢它。但是如果我们因为无法使用 Java 5 而必须使用 EasyMock 1,情况可能会有所不同:实现模拟版本会丑陋得多。如下所示
MockControl controlCollaborator = MockControl.createControl(Collaborator.class);
Collaborator collaborator = (Collaborator) controlCollaborator.getMock();
MockControl controlTask = MockControl.createControl(Task.class);
Task task = (Task) controlTask.getMock();
controlCollaborator.expectAndReturn(collaborator.getTask(), task);
controlTask.expectAndReturn(task.isActive(), true);
controlTask.replay();
controlCollaborator.replay();
service.setCollaborator(collaborator);
assertTrue(service.isActive());
controlCollaborator.verify();
controlTask.verify();
测试的长度又增加了半倍,相应地更难阅读和维护。实际情况很容易变得更糟。在这种情况下,为了轻松起见,我们可以考虑存根实现。当然,模拟对象的忠实信徒会指出,这是一种虚假的节俭,与使用存根的测试相比,单元测试会更健壮,并且更适合长期维护。