使用 Mockito – 一个流行的模拟库

前面的部分展示了使用存根和模拟对象测试代码的示例。我们一直在手工编写这些测试替身。显然,这样做非常重复且耗时。这就引出了一个问题,即这种重复的样板代码是否可以自动化。幸运的是,它可以。本节将回顾流行的 Mockito 库中可用的帮助。

Mockito 是一个在 MIT 许可证下的免费开源库。这个许可证意味着我们可以在商业开发工作中使用它,但需要得到我们工作单位的同意。Mockito 提供了大量功能,旨在用很少的代码创建测试替身。Mockito 网站可以在 https://site.mockito.org/ 找到。

开始使用 Mockito

开始使用 Mockito 很简单。我们在 Gradle 文件中引入 Mockito 库和一个扩展库。扩展库允许 MockitoJUnit5 紧密集成。

build.gradle 的摘录如下所示:

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
    testImplementation 'org.assertj:assertj-core:3.22.0'
    testImplementation 'org.mockito:mockito-core:4.8.0'
    testImplementation 'org.mockito:mockito-junit-jupiter:4.8.0'
}

使用 Mockito 编写存根

让我们看看 Mockito 如何帮助我们创建一个存根对象。我们将使用 TDD 创建一个 UserGreeting 类,该类在从 UserProfiles 接口获取我们的昵称后提供个性化的问候语。

让我们使用小步骤编写这个,看看 TDDMockito 如何一起工作:

  1. 编写基本的 JUnit5 测试类并将其与 Mockito 集成:

    package examples;
    
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.mockito.junit.jupiter.MockitoExtension;
    
    @ExtendWith(MockitoExtension.class)
    public class UserGreetingTest {
    }

    @ExtendWith(MockitoExtension.class) 标记此测试为使用 Mockito。当我们运行这个 JUnit5 测试时,该注解确保 Mockito 库代码被运行。

  2. 添加一个测试以确认预期行为。我们将在断言中捕获这一点:

    package examples;
    
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.mockito.junit.jupiter.MockitoExtension;
    
    import static org.assertj.core.api.Assertions.assertThat;
    
    @ExtendWith(MockitoExtension.class)
    public class UserGreetingTest {
        @Test
        void formatsGreetingWithName() {
            String actual = "";
            assertThat(actual).isEqualTo("Hello and welcome, Alan");
        }
    }

    这是我们之前见过的 JUnit 和 AssertJ 框架的标准用法。如果我们现在运行测试,它将失败。

  3. 使用 Act 步骤驱动出我们的 SUT——我们想要编写的类:

    package examples;
    
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.mockito.junit.jupiter.MockitoExtension;
    
    import static org.assertj.core.api.Assertions.assertThat;
    
    @ExtendWith(MockitoExtension.class)
    public class UserGreetingTest {
        private static final UserId USER_ID = new UserId("1234");
    
        @Test
        void formatsGreetingWithName() {
            var greeting = new UserGreeting();
            String actual = greeting.formatGreeting(USER_ID);
            assertThat(actual).isEqualTo("Hello and welcome, Alan");
        }
    }

    这个步骤驱动出两个新的生产代码类,如下面的步骤所示。

  4. 添加 UserGreeting 类的骨架:

    package examples;
    
    public class UserGreeting {
        public String formatGreeting(UserId id) {
            throw new UnsupportedOperationException();
        }
    }

    像往常一样,我们只添加使我们的测试编译所需的代码。这里捕获的设计决策显示我们的行为由 formatGreeting() 方法提供,该方法通过 UserId 类识别用户。

  5. 添加 UserId 类的骨架:

    package examples;
    
    public class UserId {
        public UserId(String id) {
        }
    }

    再次,我们只得到一个空壳以使测试编译。然后,我们运行测试,它仍然失败:

    image 2025 01 12 16 21 11 671
    Figure 1. Figure 8.3 – Test failure
  6. 另一个要捕获的设计决策是 UserGreeting 类将依赖于 UserProfiles 接口。我们需要创建一个字段,创建接口骨架,并在 SUT 的新构造函数中注入该字段:

    package examples;
    
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.mockito.junit.jupiter.MockitoExtension;
    
    import static org.assertj.core.api.Assertions.assertThat;
    
    @ExtendWith(MockitoExtension.class)
    public class UserGreetingTest {
        private static final UserId USER_ID = new UserId("1234");
    
        private UserProfiles profiles;
    
        @Test
        void formatsGreetingWithName() {
            var greeting = new UserGreeting(profiles);
            String actual = greeting.formatGreeting(USER_ID);
            assertThat(actual).isEqualTo("Hello and welcome, Alan");
        }
    }

    我们继续添加使测试编译所需的最少代码。如果我们运行测试,它仍然会失败。但我们已经进一步推进,所以失败现在是一个 UnsupportedOperationException 错误。这确认了 formatGreeting() 已被调用:

    image 2025 01 12 16 21 43 333
    Figure 2. Figure 8.4 – Failure confirms method call
  7. formatGreeting() 方法添加行为:

    package examples;
    
    public class UserGreeting {
        private final UserProfiles profiles;
    
        public UserGreeting(UserProfiles profiles) {
            this.profiles = profiles;
        }
    
        public String formatGreeting(UserId id) {
            return String.format("Hello and Welcome, %s", profiles.fetchNicknameFor(id));
        }
    }
  8. UserProfiles 接口添加 fetchNicknameFor()

    package examples;
    
    public interface UserProfiles {
        String fetchNicknameFor(UserId id);
    }
  9. 运行测试。它将因空异常而失败:

    image 2025 01 12 16 22 20 716
    Figure 3. Figure 8.5 – Null exception failure

    测试失败是因为我们将 profiles 字段作为依赖项传递到我们的 SUT 中,但该字段从未被初始化。这就是 Mockito 发挥作用的地方(终于)。

  10. profiles 字段添加 @Mock 注解:

    package examples;
    
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.mockito.Mock;
    import org.mockito.junit.jupiter.MockitoExtension;
    
    import static org.assertj.core.api.Assertions.assertThat;
    
    @ExtendWith(MockitoExtension.class)
    public class UserGreetingTest {
        private static final UserId USER_ID = new UserId("1234");
    
        @Mock
        private UserProfiles profiles;
    
        @Test
        void formatsGreetingWithName() {
            var greeting = new UserGreeting(profiles);
    
            String actual = greeting.formatGreeting(USER_ID);
    
            assertThat(actual).isEqualTo("Hello and welcome, Alan");
        }
    }

    现在运行测试会产生不同的失败,因为我们尚未配置 Mockito 模拟:

    image 2025 01 12 16 23 49 062
    Figure 4. Figure 8.6 – Added mock, not configured
  11. 配置 @Mock 以返回我们测试的正确存根数据:

    package examples;
    
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.mockito.Mock;
    import org.mockito.Mockito;
    import org.mockito.junit.jupiter.MockitoExtension;
    
    import static org.assertj.core.api.Assertions.assertThat;
    import static org.mockito.Mockito.*;
    
    @ExtendWith(MockitoExtension.class)
    public class UserGreetingTest {
        private static final UserId USER_ID = new UserId("1234");
    
        @Mock
        private UserProfiles profiles;
    
        @Test
        void formatsGreetingWithName() {
            when(profiles.fetchNicknameFor(USER_ID)).thenReturn("Alan");
            var greeting = new UserGreeting(profiles);
            String actual = greeting.formatGreeting(USER_ID);
            assertThat(actual).isEqualTo("Hello and welcome, Alan");
        }
    }
  12. 如果你再次运行测试,它将因问候文本中的错误而失败。修复此问题然后重新运行测试,它将通过:

image 2025 01 12 16 25 23 818
Figure 5. Figure 8.7 – Test pass

我们刚刚创建了 UserGreeting 类,它通过 UserProfiles 接口访问用户的存储昵称。该接口使用 DIPUserGreeting 与该存储的任何实现细节隔离。我们使用存根实现来编写测试。我们遵循了 TDD 并利用 Mockito 为我们编写了该存根。

你还会注意到测试在最后一步失败了。我预计那一步会通过。但它没有,因为我打错了问候消息。再次,TDD 救了我。

使用 Mockito 编写模拟

Mockito 可以像创建存根一样轻松地创建模拟对象。我们仍然可以在我们希望成为模拟的字段上使用 @Mock 注解——也许终于理解了该注解的含义。我们使用 Mockitoverify() 方法来检查我们的 SUT 是否在协作者上调用了预期的方法。

让我们看看如何使用模拟对象。我们将为一些 SUT 代码编写测试,我们期望这些代码通过 MailServer 发送电子邮件:

@ExtendWith(MockitoExtension.class)
class WelcomeEmailTest {
    @Mock
    private MailServer mailServer;

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

        notifications.welcomeNewUser("test@example.com");

        verify(mailServer).sendEmail("test@example.com", "Welcome!", "Welcome to your account");
    }
}

在这个测试中,我们看到 @ExtendWith(MockitoExtension.class) 注解用于初始化 Mockito,以及我们测试方法中熟悉的 ArrangeActAssert 格式。这里的新想法是在断言中。我们使用 Mockito 库中的 verify() 方法来检查我们的 SUT 是否正确调用了 sendEmail() 方法。该检查还验证了它是否使用正确的参数值调用。

Mockito 使用代码生成来实现这一切。它包装了我们用 @Mock 注解标记的接口,并拦截每一个调用。它存储每个调用的参数值。当我们使用 verify() 方法确认方法是否正确调用时,Mockito 拥有它所需的所有数据。

注意 Mockitowhen()verify() 语法!

Mockitowhen()verify() 语法有细微差别:

  • when(object.method()).thenReturn(expected value);

  • verify(object).method();

模糊存根和模拟之间的区别

关于 Mockito 术语需要注意的一点是,它模糊了存根和模拟对象之间的区别。在 Mockito 中,我们创建被标记为模拟对象的测试替身。但在我们的测试中,我们可以将这些替身用作存根、模拟对象,甚至两者的混合。

将测试替身设置为既是存根又是模拟对象是一种测试代码异味。这不是错误的,但值得停下来思考一下。我们应该考虑我们既模拟又存根的协作者是否混淆了一些职责。拆分该对象可能是有益的。

参数匹配器 – 自定义测试替身的行为

到目前为止,我们已经配置了 Mockito 测试替身以响应它们替换的方法的非常特定的输入。前面的 MailServer 示例检查了传递给 sendEmail() 方法调用的三个特定参数值。但有时我们希望测试替身具有更大的灵活性。

Mockito 提供了称为参数匹配器的库方法。这些是静态方法,用于 when()verify() 语句内部。参数匹配器用于指示 Mockito 响应可能传递到被测试方法中的一系列参数值——包括空值和未知值。

以下测试使用了一个接受任何 UserId 值的参数匹配器:

package examples2;

import examples.UserGreeting;
import examples.UserId;
import examples.UserProfiles;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
public class UserGreetingTest {
    @Mock
    private UserProfiles profiles;

    @Test
    void formatsGreetingWithName() {
        when(profiles.fetchNicknameFor(any())).thenReturn("Alan");

        var greeting = new UserGreeting(profiles);

        String actual = greeting.formatGreeting(new UserId(""));

        assertThat(actual).isEqualTo("Hello and welcome, Alan");
    }
}

我们向 fetchNicknameFor() 方法的存根添加了一个 any() 参数匹配器。这指示 Mockito 无论传递什么参数值到 fetchNicknameFor(),都返回预期值 Alan。这在编写测试以引导我们的读者并帮助他们理解特定测试中什么是重要的和什么是不重要的时候非常有用。

Mockito 提供了许多参数匹配器,在 Mockito 官方文档中有描述。这些参数匹配器在创建存根以模拟错误条件时特别有用。这是下一节的主题。