使用 Mockito – 一个流行的模拟库
前面的部分展示了使用存根和模拟对象测试代码的示例。我们一直在手工编写这些测试替身。显然,这样做非常重复且耗时。这就引出了一个问题,即这种重复的样板代码是否可以自动化。幸运的是,它可以。本节将回顾流行的 Mockito
库中可用的帮助。
Mockito
是一个在 MIT 许可证下的免费开源库。这个许可证意味着我们可以在商业开发工作中使用它,但需要得到我们工作单位的同意。Mockito
提供了大量功能,旨在用很少的代码创建测试替身。Mockito
网站可以在 https://site.mockito.org/ 找到。
开始使用 Mockito
开始使用 Mockito
很简单。我们在 Gradle
文件中引入 Mockito
库和一个扩展库。扩展库允许 Mockito
与 JUnit5
紧密集成。
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
接口获取我们的昵称后提供个性化的问候语。
让我们使用小步骤编写这个,看看 TDD 和 Mockito
如何一起工作:
-
编写基本的 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
库代码被运行。 -
添加一个测试以确认预期行为。我们将在断言中捕获这一点:
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
框架的标准用法。如果我们现在运行测试,它将失败。 -
使用
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"); } }
这个步骤驱动出两个新的生产代码类,如下面的步骤所示。
-
添加
UserGreeting
类的骨架:package examples; public class UserGreeting { public String formatGreeting(UserId id) { throw new UnsupportedOperationException(); } }
像往常一样,我们只添加使我们的测试编译所需的代码。这里捕获的设计决策显示我们的行为由
formatGreeting()
方法提供,该方法通过UserId
类识别用户。 -
添加
UserId
类的骨架:package examples; public class UserId { public UserId(String id) { } }
再次,我们只得到一个空壳以使测试编译。然后,我们运行测试,它仍然失败:
Figure 1. Figure 8.3 – Test failure -
另一个要捕获的设计决策是
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()
已被调用:Figure 2. Figure 8.4 – Failure confirms method call -
向
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)); } }
-
向
UserProfiles
接口添加fetchNicknameFor()
:package examples; public interface UserProfiles { String fetchNicknameFor(UserId id); }
-
运行测试。它将因空异常而失败:
Figure 3. Figure 8.5 – Null exception failure测试失败是因为我们将
profiles
字段作为依赖项传递到我们的 SUT 中,但该字段从未被初始化。这就是Mockito
发挥作用的地方(终于)。 -
向
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
模拟:Figure 4. Figure 8.6 – Added mock, not configured -
配置
@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"); } }
-
如果你再次运行测试,它将因问候文本中的错误而失败。修复此问题然后重新运行测试,它将通过:

我们刚刚创建了 UserGreeting
类,它通过 UserProfiles
接口访问用户的存储昵称。该接口使用 DIP 将 UserGreeting
与该存储的任何实现细节隔离。我们使用存根实现来编写测试。我们遵循了 TDD 并利用 Mockito
为我们编写了该存根。
你还会注意到测试在最后一步失败了。我预计那一步会通过。但它没有,因为我打错了问候消息。再次,TDD 救了我。
使用 Mockito 编写模拟
Mockito
可以像创建存根一样轻松地创建模拟对象。我们仍然可以在我们希望成为模拟的字段上使用 @Mock
注解——也许终于理解了该注解的含义。我们使用 Mockito
的 verify()
方法来检查我们的 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
,以及我们测试方法中熟悉的 Arrange
、Act
和 Assert
格式。这里的新想法是在断言中。我们使用 Mockito
库中的 verify()
方法来检查我们的 SUT 是否正确调用了 sendEmail()
方法。该检查还验证了它是否使用正确的参数值调用。
Mockito
使用代码生成来实现这一切。它包装了我们用 @Mock
注解标记的接口,并拦截每一个调用。它存储每个调用的参数值。当我们使用 verify()
方法确认方法是否正确调用时,Mockito
拥有它所需的所有数据。
注意
Mockito 的 when() 和 verify() 语法!
|
模糊存根和模拟之间的区别
关于 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
官方文档中有描述。这些参数匹配器在创建存根以模拟错误条件时特别有用。这是下一节的主题。