使用桩和模拟对象的单元测试

工程 | 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;
    }
}

桩协作者除了返回我们测试所需的value值外,什么也不做。

通常会看到这样的桩内联实现为匿名内部类,例如:


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());

在长度方面(忽略适配器基类中的代码,我们可以在其他测试中重用这些代码),两者之间没有太大区别。模拟版本更健壮(如上所述),因此我们更喜欢它。但是,如果我们必须使用EasyMock 1,因为我们无法使用Java 5,情况可能会有所不同:实现模拟版本将更加丑陋。这是:


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社区中所有即将举行的活动。

查看全部