使用模拟验证交互

在本节中,我们将看看另一种重要的测试替身:模拟对象。模拟对象解决的问题与存根对象略有不同,正如我们将在本节中看到的那样。

模拟对象是一种记录交互的测试替身。与存根不同,存根向 SUT 提供已知的对象,而模拟对象将简单地记录SUT与模拟对象的交互。它是回答 “SUT 是否正确调用了方法?” 这个问题的完美工具。这解决了 SUT 与其协作者之间的推送模型交互问题。SUT 命令协作者做某事,而不是向它请求某事。模拟对象提供了一种验证它是否发出了该命令以及任何必要参数的方法。

以下 UML 对象图显示了一般安排:

image 2025 01 12 16 09 11 672
Figure 1. Figure 8.2 – Replace collaborator with mock

我们看到我们的测试代码将模拟对象连接到 SUTAct 步骤将使 SUT 执行我们期望与其协作者交互的代码。我们已经将该协作者替换为模拟对象,它将记录某个方法被调用的事实。

让我们看一个具体的例子来更容易理解这一点。假设我们的 SUT 预期向用户发送电子邮件。再次,我们将使用依赖倒置原则将我们的邮件服务器抽象为一个接口:

public interface MailServer {
    void sendEmail(String recipient, String subject, String text);
}

前面的代码显示了一个简化的接口,仅适用于发送短文本电子邮件。它足以满足我们的目的。为了测试调用此接口上的 sendEmail() 方法的 SUT,我们将编写一个 MockMailServer 类:

public class MockMailServer implements MailServer {
    boolean wasCalled;
    String actualRecipient;
    String actualSubject;
    String actualText;

    @Override
    public void sendEmail(String recipient, String subject, String text) {
        wasCalled = true;
        actualRecipient = recipient;
        actualSubject = subject;
        actualText = text;
    }
}

前面的 MockMailServer 类实现了 MailServer 接口。它有一个单一的责任——记录 sendEmail() 方法被调用的事实,并捕获发送到该方法的实际参数值。它将这些作为具有包公共可见性的简单字段暴露出来。我们的测试代码可以使用这些字段来形成断言。我们的测试只需将此模拟对象连接到 SUT,使 SUT 执行我们期望调用 sendEmail() 方法的代码,然后检查它是否确实这样做了:

@Test
public void sendsWelcomeEmail() {
    var mailServer = new MockMailServer();
    var notifications = new UserNotifications(mailServer);

    notifications.welcomeNewUser();

    assertThat(mailServer.wasCalled).isTrue();

    assertThat(mailServer.actualRecipient).isEqualTo("test@example.com");

    assertThat(mailServer.actualSubject).isEqualTo("Welcome!");

    assertThat(mailServer.actualText).contains("Welcome to your account");
}

我们可以看到,这个测试将模拟对象连接到我们的 SUT,然后使 SUT 执行 welcomeNewUser() 方法。我们期望此方法调用 MailServer 对象上的 sendEmail() 方法。然后,我们需要编写断言以确认该调用是否使用正确的参数值进行。我们在这里逻辑上使用了四个断言语句并测试一个想法——实际上是一个单一的断言。

模拟对象的力量在于我们可以记录与难以控制的对象的交互。在前面的代码块中看到的邮件服务器的情况下,我们不想向任何人发送实际电子邮件。我们也不想编写一个测试来等待监视测试用户的邮箱。这不仅慢且可能不可靠,而且也不是我们打算测试的内容。SUT 只有责任调用 sendEmail() ——之后发生的事情超出了 SUT 的范围。因此,它超出了此测试的范围。

与前面的其他测试替身示例一样,我们使用了 依赖倒置原则,这意味着我们的生产代码足够容易创建。我们只需要创建一个使用 SMTP 协议与真实邮件服务器通信的 MailServer 实现。我们很可能会搜索一个已经为我们做到这一点的库类,然后我们需要制作一个非常简单的适配器对象,将该库代码绑定到我们的接口。

本节涵盖了两种常见的测试替身,存根和模拟对象。但测试替身并不总是适合使用。在下一节中,我们将讨论使用测试替身时需要注意的一些问题。