使用存根和模拟进行单元测试

工程 | Dave Syer | 2007 年 1 月 15 日 | ...

前几天我与一些客户在一起,他们问我关于单元测试和模拟对象的问题。我决定将我们讨论的一些内容写成一篇关于为单元测试创建依赖项(协作者)的教程。我们讨论了两种选择,存根和模拟对象,并给出了一些简单的例子来说明两者的用法、优点和缺点。

在单元测试中,为了使测试独立于被测类的协作者的实现,通常会模拟(mock)或存根(stub)协作者。能够精确控制测试中使用的数据,并验证单元是否按预期工作,也是一项有用的能力。

存根(Stubbing)

存根方法易于使用,并且不为单元测试引入额外的依赖。基本技术是实现协作者的具体类,这些类仅表现出协作者的整体行为中被测试类所需的小部分。例如,考虑一个服务实现正在被测试的情况。该实现有一个协作者。


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 可以自动在所有测试用例中进行名称或签名更改。

内联存根方法非常有用且实现快捷,但为了更精确地控制测试用例,并确保如果服务对象的实现发生变化,测试用例也相应地发生变化,那么模拟对象(mock object)的方法更好。

模拟对象(Mock Objects)

使用模拟对象(例如,来自 EasyMockJMock)我们可以对被测单元的内部实现进行高度控制的测试。

为了在实践中看到这一点,请考虑上面的示例,并使用 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 的上述测试将因一条显而易见的错误消息而失败,该消息指出未执行协作者上的预期方法调用。在存根实现中,测试可能失败,也可能不失败:如果失败,错误消息将是晦涩难懂的;如果不失败,那么这仅仅是偶然的。

要修复失败的测试,我们必须修改它以反映服务的内部实现。一些人认为,为了反映实现细节而不断重写测试用例是一种负担,但实际上,单元测试的本质就是必须这样做。我们测试的是单元的实现,而不是它与系统的其他部分的契约。要测试契约,我们将使用集成测试,并将服务视为一个黑盒,由其接口而不是其实现来定义。

EasyMock 2

请注意,如果我们使用 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();

测试的长度增加了近一半,相应地也更难阅读和维护。在实际情况中,事情很容易变得更糟。在这种情况下,为了省事,我们可能会考虑存根实现。当然,模拟对象的忠实信徒会指出,这是一种虚假的经济,而单元测试将比使用存根的测试更健壮,对长期发展更好。

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,助您加速进步。

了解更多

获得支持

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件,只需一份简单的订阅。

了解更多

即将举行的活动

查看 Spring 社区所有即将举行的活动。

查看所有