单元测试中的存根(Stubs)和模拟对象(Mocks)

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

前几天我在客户那里,他们问了我关于单元测试和模拟对象的问题。我决定将我们讨论的一些内容写成一篇关于为单元测试创建依赖(协作者)的教程。我们讨论了两种方案:存根和模拟对象,并给出了一些简单的例子来**阐明**它们的用法以及两种方法的优缺点。

在单元测试中,模拟或存根被测类的协作者是很常见的,这样测试就独立于协作者的实现。这样做也很有用,可以精确控制测试中使用的数据,并验证单元的行为是否符合预期。

存根

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


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

内联存根方法非常有用且实现快速,但为了对测试用例有更多的控制权,并确保如果服务对象的实现发生变化,测试用例也相应变化,模拟对象方法更好。

模拟对象

使用模拟对象(例如来自 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 社区中所有即将举行的活动。

查看全部