个性化阅读
专注于IT技术分析

日常Mockito的单元测试从业人员指南

本文概述

在敏捷时代, 单元测试已成为必不可少的工具, 并且有许多工具可用于自动化测试。这样的工具就是Mockito, 它是一个开放源代码框架, 可让你创建和配置模拟对象以进行测试。

在本文中, 我们将介绍如何创建和配置模拟, 并使用它们来验证被测系统的预期行为。我们还将深入研究Mockito的内部结构, 以更好地了解其设计和注意事项。我们将使用JUnit作为单元测试框架, 但是由于Mockito并未绑定到JUnit, 因此即使你使用其他框架, 也可以继续使用。

获取Mockito

这些天来获取Mockito很容易。如果你使用的是Gradle, 则可以将以下一行添加到构建脚本中:

testCompile "org.mockito:mockito−core:2.7.7"

对于像我这样仍然喜欢Maven的人, 只需将Mockito添加到你的依赖项中, 如下所示:

<dependency> 
    <groupId>org.mockito</groupId> 
    <artifactId>mockito-core</artifactId> 
    <version>2.7.7</version> 
    <scope>test</scope> 
</dependency>

当然, 世界比Maven和Gradle更广阔。你可以随意使用任何项目管理工具从Maven中央存储库中获取Mockito jar工件。

接近Mockito

单元测试旨在测试特定类或方法的行为, 而不依赖于其依赖项的行为。由于我们正在测试最小的代码”单元”, 因此我们不需要使用这些依赖项的实际实现。此外, 在测试不同的行为时, 我们将对这些依赖项使用稍微不同的实现。一种传统的, 众所周知的方法是创建”存根”(stub), 即适合给定场景的接口的特定实现。这样的实现通常具有硬编码逻辑。存根是一种测试双。其他种类包括假货, 假货, 间谍, 假人等。

我们将只关注两种类型的测试双打, 即”模拟”和”间谍”, 这是Mockito大量使用的。

嘲弄

什么在嘲笑?显然, 这不是你嘲笑其他开发人员的地方。模拟用于单元测试是在你创建一个对象时, 该对象以受控方式实现实际子系统的行为。简而言之, 模拟被用来替代依赖项。

使用Mockito, 你可以创建一个模拟, 告诉Mockito当调用特定方法时该怎么做, 然后在测试中使用模拟实例代替真实实例。测试后, 你可以查询该模拟, 以查看调用了哪些特定方法, 或检查状态更改后的副作用。

默认情况下, Mockito为每种模拟方法提供一个实现。

间谍

间谍是Mockito创建的另一种测试类型。与模拟相反, 创建间谍需要监视一个实例。默认情况下, 间谍将所有方法调用都委派给真实对象, 并记录调用的方法和参数。这就是使它成为间谍的原因:它是在监视真实物体。

考虑尽可能使用模拟而不是间谍。间谍对于测试无法重新设计为易于测试的遗留代码可能很有用, 但是需要使用间谍来部分模拟一个类, 这表明该类做得太多, 从而违反了单一责任原则。

建立一个简单的例子

让我们看一下我们可以编写测试的简单演示。假设我们有一个UserRepository接口, 该接口具有一个通过其标识符查找用户的方法。我们还具有密码编码器的概念, 可以将明文密码转换为密码哈希。 UserRepository和PasswordEncoder都是通过构造函数注入的UserService的依赖项(也称为协作者)。我们的演示代码如下所示:

用户资料库
public interface UserRepository {
   User findById(String id);
}
用户
public class User {

   private String id;
   private String passwordHash;
   private boolean enabled;

   public User(String id, String passwordHash, boolean enabled) {
       this.id = id;
       this.passwordHash = passwordHash;
       this.enabled = enabled;
   }
   ...
}
密码编码器
public interface PasswordEncoder {
   String encode(String password);
}
用户服务
public class UserService {

   private final UserRepository userRepository;
   private final PasswordEncoder passwordEncoder;

   public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
       this.userRepository = userRepository;
       this.passwordEncoder = passwordEncoder;
   }

   public boolean isValidUser(String id, String password) {
       User user = userRepository.findById(id);
       return isEnabledUser(user) && isValidPassword(user, password);
   }

   private boolean isEnabledUser(User user) {
       return user != null && user.isEnabled();
   }

   private boolean isValidPassword(User user, String password) {
       String encodedPassword = passwordEncoder.encode(password);
       return encodedPassword.equals(user.getPasswordHash());
   }
}

该示例代码可在GitHub上找到, 因此你可以将其下载与本文一起进行审核。

应用Mockito

使用示例代码, 让我们看看如何应用Mockito并编写一些测试。

创造嘲弄

使用Mockito, 创建模拟就像调用静态方法Mockito.mock()一样容易:

import static org.mockito.Mockito.*;
...
PasswordEncoder passwordEncoder = mock(PasswordEncoder.class);

注意Mockito的静态导入。对于本文的其余部分, 我们将隐式考虑添加了此导入。

导入后, 我们将模拟出接口PasswordEncoder。 Mockito不仅模拟接口, 还模拟抽象类和具体的非最终类。开箱即用, Mockito无法模拟最终类和最终或静态方法, 但是如果你确实需要, Mockito 2提供了实验性的MockMaker插件。

还要注意, 不能模拟equals()和hashCode()方法。

创建间谍

要创建间谍, 你需要调用Mockito的静态方法spy()并将其实例传递给间谍。返回对象的调用方法将调用真实方法, 除非这些方法已存根。将记录这些调用, 并可以验证这些调用的事实(请参见verify()的进一步说明)。让我们做个间谍:

DecimalFormat decimalFormat = spy(new DecimalFormat());
assertEquals("42", decimalFormat.format(42L));

创建间谍与创建模拟没有太大区别。此外, 用于配置模拟的所有Mockito方法也适用于配置间谍。

与模拟相比, 间谍很少使用, 但是你可能会发现它们对于测试无法重构的遗留代码很有用, 因为在测试中需要部分模拟。在这种情况下, 你只需创建一个间谍并将其某些方法存根即可获得所需的行为。

默认返回值

调用mock(PasswordEncoder.class)返回PasswordEncoder的一个实例。我们甚至可以调用它的方法, 但是它们将返回什么?默认情况下, 模拟的所有方法都返回”未初始化”或”空”值, 例如, 对于数字类型(原始类型和装箱形式)为零, 对于布尔类型为false, 对于大多数其他类型为null。

考虑以下接口:

interface Demo {
   int getInt();
   Integer getInteger();
   double getDouble();
   boolean getBoolean();
   String getObject();
   Collection<String> getCollection();
   String[] getArray();
   Stream<?> getStream();
   Optional<?> getOptional();
}

现在考虑以下代码段, 该代码段给出了模拟方法的默认值:

Demo demo = mock(Demo.class);
assertEquals(0, demo.getInt());
assertEquals(0, demo.getInteger().intValue());
assertEquals(0d, demo.getDouble(), 0d);
assertFalse(demo.getBoolean());
assertNull(demo.getObject());
assertEquals(Collections.emptyList(), demo.getCollection());
assertNull(demo.getArray());
assertEquals(0L, demo.getStream().count());
assertFalse(demo.getOptional().isPresent());

存根方法

新鲜的, 未更改的模拟仅在极少数情况下有用。通常, 我们要配置模拟并定义调用模拟的特定方法时要执行的操作。这称为存根。

Mockito提供了两种存根方法。第一种方法是”当调用此方法时, 先执行操作。”考虑以下代码段:

when(passwordEncoder.encode("1")).thenReturn("a");

它的读法几乎像是英语:”当调用passwordEncoder.encode(” 1″)时, 返回a。”

存根的第二种方式更像是”当使用以下参数调用此模拟方法时执行某些操作”。由于最后指定了原因, 因此难以理解这种存根方式。考虑:

doReturn("a").when(passwordEncoder).encode("1");

带有这种存根方法的代码片段将显示为:”当以1作为参数调用passwordEncoder的encode()方法时, 返回一个”。

第一种方法被认为是首选方法, 因为它具有类型安全性, 并且可读性强。但是, 极少数情况下, 你会被迫使用第二种方法, 例如在对间谍的真实方法进行打桩时, 因为调用它可能会产生不良的副作用。

让我们简要地探讨一下Mockito提供的存根方法。在示例中, 我们将同时包含两种存根方法。

返回值

thenReturn或doReturn()用于指定在方法调用时要返回的值。

//"when this method is called, then do something"
when(passwordEncoder.encode("1")).thenReturn("a");

or

//"do something when this mock’s method is called with the following arguments"
doReturn("a").when(passwordEncoder).encode("1");

你还可以指定多个值, 这些值将作为连续方法调用的结果返回。最后一个值将用作所有其他方法调用的结果。

//when
when(passwordEncoder.encode("1")).thenReturn("a", "b");

or

//do
doReturn("a", "b").when(passwordEncoder).encode("1");

以下代码段可以实现相同的目的:

when(passwordEncoder.encode("1"))
       .thenReturn("a")
       .thenReturn("b");

此模式也可以与其他存根方法一起使用, 以定义连续调用的结果。

返回自定义响应

then()是thenAnswer()的别名, 而doAnswer()也实现了相同的功能, 该方法设置了一个自定义答案, 以便在调用方法时返回该答案, 如下所示:

when(passwordEncoder.encode("1")).thenAnswer(
       invocation -> invocation.getArgument(0) + "!");

or

doAnswer(invocation -> invocation.getArgument(0) + "!")
       .when(passwordEncoder).encode("1");

thenAnswer()接受的唯一参数是Answer接口的实现。它具有参数类型为InvocationOnMock的单个方法。

你还可以由于方法调用而引发异常:

when(passwordEncoder.encode("1")).thenAnswer(invocation -> {
   throw new IllegalArgumentException();
});

…或调用类的实际方法(不适用于接口):

Date mock = mock(Date.class);
doAnswer(InvocationOnMock::callRealMethod).when(mock).setTime(42);
doAnswer(InvocationOnMock::callRealMethod).when(mock).getTime();
mock.setTime(42);
assertEquals(42, mock.getTime());

如果你认为它看起来很麻烦, 那你是对的。 Mockito提供thenCallRealMethod()和thenThrow()来简化测试的这一方面。

调用实数方法

顾名思义, thenCallRealMethod()和doCallRealMethod()会在模拟对象上调用真实方法:

Date mock = mock(Date.class);
when(mock.getTime()).thenCallRealMethod();
doCallRealMethod().when(mock).setTime(42);
mock.setTime(42);
assertEquals(42, mock.getTime());

调用真实方法可能对部分模拟很有用, 但请确保所调用的方法没有有害的副作用, 并且不依赖于对象状态。如果确实如此, 那么间谍可能比模拟更好。

如果创建接口的模拟并尝试配置存根以调用真实方法, 则Mockito将抛出异常, 并提供非常有用的消息。考虑以下代码段:

when(passwordEncoder.encode("1")).thenCallRealMethod();

Mockito将失败, 并显示以下消息:

Cannot call abstract real method on java object!
Calling real methods is only possible when mocking non abstract method.
  //correct example:
  when(mockOfConcreteClass.nonAbstractMethod()).thenCallRealMethod();

感谢Mockito开发人员关心的足以提供如此详尽的描述!

抛出异常

thenThrow()和doThrow()配置一个模拟方法来引发异常:

when(passwordEncoder.encode("1")).thenThrow(new IllegalArgumentException());

or

doThrow(new IllegalArgumentException()).when(passwordEncoder).encode("1");

Mockito确保所抛出的异常对该特定的存根方法有效, 并且如果该方法的已检查异常列表中没有该异常, 则将进行投诉。考虑以下:

when(passwordEncoder.encode("1")).thenThrow(new IOException());

这将导致错误:

org.mockito.exceptions.base.MockitoException: 
Checked exception is invalid for this method!
Invalid: java.io.IOException

如你所见, Mockito检测到encode()不会引发IOException。

你还可以传递异常的类, 而不是传递异常的实例:

when(passwordEncoder.encode("1")).thenThrow(IllegalArgumentException.class);

or

doThrow(IllegalArgumentException.class).when(passwordEncoder).encode("1");

也就是说, Mockito无法以与验证异常实例相同的方式来验证异常类, 因此你必须受到纪律处分, 并且不得传递非法的类对象。例如, 以下内容将引发IOException, 尽管encode()不应引发已检查的异常:

when(passwordEncoder.encode("1")).thenThrow(IOException.class);
passwordEncoder.encode("1");

使用默认方法模拟接口

值得注意的是, 在为接口创建模拟时, Mockito会模拟该接口的所有方法。从Java 8开始, 接口可能包含默认方法以及抽象方法。这些方法也被模拟, 因此你需要注意使其成为默认方法。

考虑以下示例:

interface AnInterface {
   default boolean isTrue() {
       return true;
   }
}
AnInterface mock = mock(AnInterface.class);
assertFalse(mock.isTrue());

在此示例中, assertFalse()将成功。如果这不是你所期望的, 请确保已让Mockito调用了真正的方法, 如下所示:

AnInterface mock = mock(AnInterface.class);
when(mock.isTrue()).thenCallRealMethod();
assertTrue(mock.isTrue());

参数匹配器

在前面的部分中, 我们使用精确值作为参数配置了模拟方法。在这种情况下, Mockito仅在内部调用equals()来检查期望值是否等于实际值。

不过, 有时我们不事先知道这些值。

也许我们只是不在乎将实际值作为参数传递, 或者我们想为更大范围的值定义一个反应。所有这些情况(以及更多情况)都可以使用参数匹配器解决。这个想法很简单:你没有提供确切的值, 而是为Mockito提供了一个参数匹配器来匹配方法参数。

考虑以下代码段:

when(passwordEncoder.encode(anyString())).thenReturn("exact");
assertEquals("exact", passwordEncoder.encode("1"));
assertEquals("exact", passwordEncoder.encode("abc"));

你可以看到, 无论我们将什么值传递给encode(), 结果都是相同的, 因为我们在第一行中使用了anyString()参数匹配器。如果我们用普通英语重写该行, 这听起来像是”要求密码编码器对任何字符串进行编码, 然后返回字符串”精确”。”

Mockito要求你通过匹配器或精确值提供所有参数。因此, 如果一种方法具有多个参数, 而你只想对某些参数使用参数匹配器, 则请忽略它。你不能编写这样的代码:

abstract class AClass {
   public abstract boolean call(String s, int i);
}
AClass mock = mock(AClass.class);
//This doesn’t work.
when(mock.call("a", anyInt())).thenReturn(true);

要解决该错误, 我们必须替换最后一行以为a包括eq参数匹配器, 如下所示:

when(mock.call(eq("a"), anyInt())).thenReturn(true);

在这里, 我们使用了eq()和anyInt()参数匹配器, 但还有许多其他可用的方法。有关参数匹配器的完整列表, 请参阅org.mockito.ArgumentMatchers类上的文档。

请务必注意, 你不能在验证或存根之外使用参数匹配器。例如, 你不能拥有以下内容:

//this won’t work
String orMatcher = or(eq("a"), endsWith("b"));
verify(mock).encode(orMatcher);

Mockito将检测到放错位置的参数匹配器, 并抛出InvalidUseOfMatchersException。使用参数匹配器进行验证的方式应为:

verify(mock).encode(or(eq("a"), endsWith("b")));

参数匹配器也不能用作返回值。 Mockito无法返回anyString()或任何值;存根呼叫时需要一个确切的值。

自定义匹配器

当你需要提供一些Mockito中尚不可用的匹配逻辑时, 自定义匹配器将助你一臂之力。创建自定义匹配器的决定不应轻描淡写, 因为需要以非平凡的方式匹配参数表明设计中存在问题或测试变得过于复杂。

因此, 值得在编写自定义匹配器之前检查是否可以通过使用一些宽松的参数匹配器(例如isNull()和nullable())来简化测试。如果仍然需要编写参数匹配器, 则Mockito提供了一系列方法来实现。

考虑以下示例:

FileFilter fileFilter = mock(FileFilter.class);
ArgumentMatcher<File> hasLuck = file -> file.getName().endsWith("luck");
when(fileFilter.accept(argThat(hasLuck))).thenReturn(true);
assertFalse(fileFilter.accept(new File("/deserve")));
assertTrue(fileFilter.accept(new File("/deserve/luck")));

在这里, 我们创建hasLuck参数匹配器, 并使用argThat()将匹配器作为参数传递给模拟方法, 如果文件名以” luck”结尾, 则将其存根以返回true。你可以将ArgumentMatcher视为功能接口, 并使用lambda创建其实例(这是我们在示例中所做的)。不太简洁的语法如下所示:

ArgumentMatcher<File> hasLuck = new ArgumentMatcher<File>() {
   @Override
   public boolean matches(File file) {
       return file.getName().endsWith("luck");
   }
};

如果需要创建适用于原始类型的参数匹配器, 则org.mockito.ArgumentMatchers中还有其他几种方法:

  • charThat(ArgumentMatcher <Character>匹配)
  • booleanThat(ArgumentMatcher <Boolean>匹配器)
  • byteThat(ArgumentMatcher <Byte>匹配)
  • shortThat(ArgumentMatcher <Short>匹配)
  • intThat(ArgumentMatcher <Integer>匹配)
  • longThat(ArgumentMatcher <Long>匹配)
  • floatThat(ArgumentMatcher <Float>匹配)
  • doubleThat(ArgumentMatcher <Double>匹配器)

组合匹配器

当条件太复杂而无法使用基本匹配器处理时, 创建自定义参数匹配器并不总是值得的;有时组合匹配器可以解决问题。 Mockito提供了参数匹配器, 以在匹配基本类型和非基本类型的参数匹配器上实现常见的逻辑运算(” not”, ” and”和” or”)。这些匹配器在org.mockito.AdditionalMatchers类中作为静态方法实现。

考虑以下示例:

when(passwordEncoder.encode(or(eq("1"), contains("a")))).thenReturn("ok");
assertEquals("ok", passwordEncoder.encode("1"));
assertEquals("ok", passwordEncoder.encode("123abc"));
assertNull(passwordEncoder.encode("123"));

这里我们结合了两个参数匹配器的结果:eq(” 1″)和contains(” a”)。最终表达式or(eq(” 1″), contains(” a”)), 可以解释为”参数字符串必须等于” 1″或包含” a”。

请注意, 在org.mockito.AdditionalMatchers类上列出的通用匹配器较少, 例如geq(), leq(), gt()和lt(), 它们是适用于原始值和java.lang实例的值比较。可比。

验证行为

一旦使用了模拟或间谍程序, 我们就可以验证是否发生了特定的交互。从字面上看, 我们说的是”嘿, Mockito, 请确保使用这些参数调用此方法。”

考虑以下人工示例:

PasswordEncoder passwordEncoder = mock(PasswordEncoder.class);
when(passwordEncoder.encode("a")).thenReturn("1");
passwordEncoder.encode("a");
verify(passwordEncoder).encode("a");

在这里, 我们建立了一个模拟程序, 并调用了它的encode()方法。最后一行验证是否已使用特定的参数值a调用了模拟程序的encode()方法。请注意, 验证存根调用是多余的。上一小节的目的是展示在发生某些交互后进行验证的想法。

如果我们将最后一行更改为具有不同的参数(例如b), 则先前的测试将失败, 并且Mockito将抱怨实际的调用具有不同的参数(b而不是预期的a)。

参数匹配器可用于验证, 就像存根一样:

verify(passwordEncoder).encode(anyString());

默认情况下, Mockito验证该方法被调用一次, 但是你可以验证任意数量的调用:

// verify the exact number of invocations
verify(passwordEncoder, times(42)).encode(anyString());

// verify that there was at least one invocation
verify(passwordEncoder, atLeastOnce()).encode(anyString());

// verify that there were at least five invocations
verify(passwordEncoder, atLeast(5)).encode(anyString());

// verify the maximum number of invocations
verify(passwordEncoder, atMost(5)).encode(anyString());

// verify that it was the only invocation and
// that there're no more unverified interactions
verify(passwordEncoder, only()).encode(anyString());

// verify that there were no invocations
verify(passwordEncoder, never()).encode(anyString());

verify()很少使用的功能是它在超时时失败的能力, 这主要用于测试并发代码。例如, 如果在另一个线程中与verify()同时调用我们的密码编码器, 我们可以编写如下测试:

usePasswordEncoderInOtherThread();
verify(passwordEncoder, timeout(500)).encode("a");

如果调用encode()并在500毫秒或更短的时间内完成, 则此测试将成功。如果需要等待指定的整个周期, 请使用after()而不是timeout():

verify(passwordEncoder, after(500)).encode("a");

其他验证模式(times(), atLeast()等)可以与timeout()和after()结合使用以进行更复杂的测试:

// passes as soon as encode() has been called 3 times within 500 ms
verify(passwordEncoder, timeout(500).times(3)).encode("a");

除times()之外, 受支持的验证模式还包括only(), atLeast()和atLeastOnce()(作为atLeast(1)的别名)。

Mockito还允许你在一组模拟中验证呼叫顺序。它不是经常使用的功能, 但是如果调用顺序很重要, 则可能会很有用。考虑以下示例:

PasswordEncoder first = mock(PasswordEncoder.class);
PasswordEncoder second = mock(PasswordEncoder.class);
// simulate calls
first.encode("f1");
second.encode("s1");
first.encode("f2");
// verify call order
InOrder inOrder = inOrder(first, second);
inOrder.verify(first).encode("f1");
inOrder.verify(second).encode("s1");
inOrder.verify(first).encode("f2");

如果我们重新安排模拟呼叫的顺序, 则测试将因VerificationInOrderFailure而失败。

也可以使用verifyZeroInteractions()来验证是否没有调用。此方法接受一个或多个模拟作为参数, 并且如果调用了传入的任何模拟方法, 则该方法将失败。

还值得一提的是verifyNoMoreInteractions()方法, 因为它将模拟作为参数, 并且可以用来检查对这些模拟的每次调用是否均已通过验证。

捕获参数

除了验证使用特定参数调用方法之外, Mockito还允许你捕获这些参数, 以便以后可以对它们运行自定义断言。换句话说, 你说的是”嘿, Mockito, 请验证此方法已被调用, 并提供与之一起调用的参数值。”

让我们创建一个PasswordEncoder的模型, 调用encode(), 捕获参数, 然后检查其值:

PasswordEncoder passwordEncoder = mock(PasswordEncoder.class);
passwordEncoder.encode("password");
ArgumentCaptor<String> passwordCaptor = ArgumentCaptor.forClass(String.class);
verify(passwordEncoder).encode(passwordCaptor.capture());
assertEquals("password", passwordCaptor.getValue());

如你所见, 我们将passwordCaptor.capture()传递为encode()的参数进行验证;这会在内部创建一个保存参数的参数匹配器。然后, 我们使用passwordCaptor.getValue()检索捕获的值, 并使用assertEquals()检查它。

如果我们需要捕获多个调用中的参数, 则ArgumentCaptor允许你使用getAllValues()检索所有值, 如下所示:

PasswordEncoder passwordEncoder = mock(PasswordEncoder.class);
passwordEncoder.encode("password1");
passwordEncoder.encode("password2");
passwordEncoder.encode("password3");
ArgumentCaptor<String> passwordCaptor = ArgumentCaptor.forClass(String.class);
verify(passwordEncoder, times(3)).encode(passwordCaptor.capture());
assertEquals(Arrays.asList("password1", "password2", "password3"), passwordCaptor.getAllValues());

可以使用相同的技术来捕获可变arity方法参数(也称为varargs)。

测试我们的简单示例

现在我们对Mockito有了更多的了解, 是时候回到我们的演示了。让我们编写isValidUser方法测试。可能是这样的:

public class UserServiceTest {

   private static final String PASSWORD = "password";

   private static final User ENABLED_USER =
           new User("user id", "hash", true);

   private static final User DISABLED_USER =
           new User("disabled user id", "disabled user password hash", false);
  
   private UserRepository userRepository;
   private PasswordEncoder passwordEncoder;
   private UserService userService;

   @Before
   public void setup() {
       userRepository = createUserRepository();
       passwordEncoder = createPasswordEncoder();
       userService = new UserService(userRepository, passwordEncoder);
   }

   @Test
   public void shouldBeValidForValidCredentials() {
       boolean userIsValid = userService.isValidUser(ENABLED_USER.getId(), PASSWORD);
       assertTrue(userIsValid);

       // userRepository had to be used to find a user with id="user id"
       verify(userRepository).findById(ENABLED_USER.getId());

       // passwordEncoder had to be used to compute a hash of "password"
       verify(passwordEncoder).encode(PASSWORD);
   }

   @Test
   public void shouldBeInvalidForInvalidId() {
       boolean userIsValid = userService.isValidUser("invalid id", PASSWORD);
       assertFalse(userIsValid);

       InOrder inOrder = inOrder(userRepository, passwordEncoder);
       inOrder.verify(userRepository).findById("invalid id");
       inOrder.verify(passwordEncoder, never()).encode(anyString());
   }

   @Test
   public void shouldBeInvalidForInvalidPassword() {
       boolean userIsValid = userService.isValidUser(ENABLED_USER.getId(), "invalid");
       assertFalse(userIsValid);

       ArgumentCaptor<String> passwordCaptor = ArgumentCaptor.forClass(String.class);
       verify(passwordEncoder).encode(passwordCaptor.capture());
       assertEquals("invalid", passwordCaptor.getValue());
   }

   @Test
   public void shouldBeInvalidForDisabledUser() {
       boolean userIsValid = userService.isValidUser(DISABLED_USER.getId(), PASSWORD);
       assertFalse(userIsValid);

       verify(userRepository).findById(DISABLED_USER.getId());
       verifyZeroInteractions(passwordEncoder);
   }

   private PasswordEncoder createPasswordEncoder() {
       PasswordEncoder mock = mock(PasswordEncoder.class);
       when(mock.encode(anyString())).thenReturn("any password hash");
       when(mock.encode(PASSWORD)).thenReturn(ENABLED_USER.getPasswordHash());
       return mock;
   }

   private UserRepository createUserRepository() {
       UserRepository mock = mock(UserRepository.class);
       when(mock.findById(ENABLED_USER.getId())).thenReturn(ENABLED_USER);
       when(mock.findById(DISABLED_USER.getId())).thenReturn(DISABLED_USER);
       return mock;
   }
}

在API下潜水

Mockito提供了一种易读, 方便的API, 但让我们探究其一些内部工作原理, 以了解其局限性并避免奇怪的错误。

让我们检查一下运行以下代码段时Mockito内部的情况:

// 1: create
PasswordEncoder mock = mock(PasswordEncoder.class);
// 2: stub
when(mock.encode("a")).thenReturn("1");
// 3: act
mock.encode("a");
// 4: verify
verify(mock).encode(or(eq("a"), endsWith("b")));

显然, 第一行创建了一个模拟。 Mockito使用ByteBuddy创建给定类的子类。新的类对象具有一个生成的名称, 如demo.mockito.PasswordEncoder $ MockitoMock $ 1953422997, 其equals()将用作检查身份, hashCode()将返回一个身份哈希码。一旦生成并加载了类, 就使用Objenesis创建其实例。

让我们看下一行:

when(mock.encode("a")).thenReturn("1");

顺序很重要:在这里执行的第一条语句是mock.encode(” a”), 它将在模拟上调用带有默认返回值null的encode()。所以说真的, 我们正在传递null作为when()的参数。 Mockito不在乎将确切的值传递给when(), 因为它在调用该方法时将有关模拟方法调用的信息存储在所谓的”正在进行的存根”中。稍后, 当我们调用when()时, Mockito会拉取正在进行的存根对象, 并将其作为when()的结果返回。然后, 在返回的正在进行的存根对象上调用thenReturn(” 1″)。

第三行, mock.encode(” a”);很简单:我们正在调用存根方法。在内部, Mockito保存此调用以进行进一步的验证, 并返回存根的调用答案。在我们的例子中, 它是字符串1。

在第四行(verify(mock).encode(or(eq(” a”), endsWith(” b”)))))中, 我们要求Mockito验证是否有对那些调用了encode()具体的论点。

首先执行verify(), 它将Mockito的内部状态转换为验证模式。重要的是要了解Mockito将其状态保存在ThreadLocal中。这样可以实现一种不错的语法, 但是, 另一方面, 如果不正确使用框架(例如, 如果你尝试在验证或存根之外使用参数匹配器), 则可能导致奇怪的行为。

那么Mockito如何创建匹配器?首先, 调用eq(” a”), 并将equals匹配器添加到matchers堆栈中。其次, 调用endsWith(” b”), 并将endsWith匹配器添加到堆栈中。最后, 调用or(null, null)-它使用从堆栈弹出的两个匹配器, 创建or匹配器, 然后将其推入堆栈。最后, 调用encode()。然后Mockito验证该方法已被调用了预期的次数以及预期的参数。

虽然参数匹配器不能提取到变量中(因为它会更改调用顺序), 但可以将它们提取到方法中。这样可以保留调用顺序, 并使堆栈保持正确的状态:

verify(mock).encode(matchCondition());
…
String matchCondition() {
   return or(eq("a"), endsWith("b"));
}

更改默认答案

在前面的部分中, 我们以这样的方式创建了模拟, 当调用任何模拟方法时, 它们将返回”空”值。此行为是可配置的。如果Mockito提供的功能不合适, 你甚至可以提供自己的org.mockito.stubbing.Answer实现, 但这可能表明单元测试过于复杂时出现了问题。记住KISS原则!

让我们探讨一下Mockito提供的预定义默认答案:

  • RETURNS_DEFAULTS是默认策略;设置模拟游戏时, 不值得一提。

  • CALLS_REAL_METHODS使未打桩的调用调用真实方法。

  • 当使用未存根方法调用返回的对象时, RETURNS_SMART_NULLS通过返回SmartNull而不是null来避免NullPointerException。你仍然会因为NullPointerException而失败, 但是SmartNull可以通过调用未存根方法的行为你提供更好的堆栈跟踪。这使得值得在Mockito中将RETURNS_SMART_NULLS作为默认答案!

  • RETURNS_MOCKS首先尝试返回普通的”空”值, 然后进行模拟(如果可能), 否则返回null。空虚的条件与我们之前看到的有所不同:用RETURNS_MOCKS创建的模拟并没有返回字符串和数组的null, 而是分别返回了空字符串和空数组。

  • RETURNS_SELF对于模拟生成器很有用。使用此设置, 如果调用的方法返回的类型等于被模拟类的类(或超类), 则模拟将返回其自身的实例。

  • RETURNS_DEEP_STUBS比RETURNS_MOCKS更深入, 并创建了能够从模拟等中的模拟返回模拟的模拟。与RETURNS_MOCKS相比, RETURNS_DEEP_STUBS中的空规则是默认的, 因此它为字符串和数组返回null:

interface We { Are we(); }
interface Are { So are(); }
interface So { Deep so(); }
interface Deep { boolean deep(); }
...
We mock = mock(We.class, Mockito.RETURNS_DEEP_STUBS);
when(mock.we().are().so().deep()).thenReturn(true);
assertTrue(mock.we().are().so().deep());

命名模拟

Mockito允许你命名一个模拟, 如果你在测试中有很多模拟并且需要区分它们, 则此功能很有用。就是说, 需要命名模拟可能是不良设计的征兆。考虑以下:

PasswordEncoder robustPasswordEncoder = mock(PasswordEncoder.class);
PasswordEncoder weakPasswordEncoder = mock(PasswordEncoder.class);
verify(robustPasswordEncoder).encode(anyString());

Mockito会抱怨, 但是由于我们尚未正式命名该模拟, 因此我们不知道哪个模拟:

Wanted but not invoked:
passwordEncoder.encode(<any string>);

让我们通过在构造字符串中传递它们来命名它们:

PasswordEncoder robustPasswordEncoder = mock(PasswordEncoder.class, "robustPasswordEncoder");
PasswordEncoder weakPasswordEncoder = mock(PasswordEncoder.class, "weakPasswordEncoder");
verify(robustPasswordEncoder).encode(anyString());

现在, 错误消息更加友好, 并且明确指向了robustPasswordEncoder:

Wanted but not invoked:
robustPasswordEncoder.encode(<any string>);

实现多个模拟接口

有时, 你可能希望创建一个实现多个接口的模拟程序。 Mockito可以轻松做到这一点, 就像这样:

PasswordEncoder mock = mock(
       PasswordEncoder.class, withSettings().extraInterfaces(List.class, Map.class));
assertTrue(mock instanceof List);
assertTrue(mock instanceof Map);

听力调用

可以将模拟程序配置为在每次调用模拟方法时调用一次调用侦听器。在侦听器内部, 你可以找出调用是否产生了值或是否引发了异常。

InvocationListener invocationListener = new InvocationListener() {
   @Override
   public void reportInvocation(MethodInvocationReport report) {
       if (report.threwException()) {
           Throwable throwable = report.getThrowable();
           // do something with throwable
           throwable.printStackTrace();
       } else {
           Object returnedValue = report.getReturnedValue();
           // do something with returnedValue
           System.out.println(returnedValue);
       }
   }
};
PasswordEncoder passwordEncoder = mock(
       PasswordEncoder.class, withSettings().invocationListeners(invocationListener));
passwordEncoder.encode("1");

在此示例中, 我们将返回值或堆栈跟踪信息转储到系统输出流中。我们的实现与Mockito的org.mockito.internal.debugging.VerboseMockInvocationLogger大致相同(不要直接使用, 它是内部的东西)。如果日志调用是侦听器唯一需要的功能, 则Mockito提供了一种更干净的方法来通过verboseLogging()设置表达你的意图:

PasswordEncoder passwordEncoder = mock(
       PasswordEncoder.class, withSettings().verboseLogging());

不过请注意, 即使你使用方法, Mockito也会调用侦听器。考虑以下示例:

PasswordEncoder passwordEncoder = mock(
       PasswordEncoder.class, withSettings().verboseLogging());
// listeners are called upon encode() invocation
when(passwordEncoder.encode("1")).thenReturn("encoded1");
passwordEncoder.encode("1");
passwordEncoder.encode("2");

该片段将产生类似于以下内容的输出:

############ Logging method invocation #1 on mock/spy ########
passwordEncoder.encode("1");
   invoked: -> at demo.mockito.MockSettingsTest.verboseLogging(MockSettingsTest.java:85)
   has returned: "null"

############ Logging method invocation #2 on mock/spy ########
   stubbed: -> at demo.mockito.MockSettingsTest.verboseLogging(MockSettingsTest.java:85)
passwordEncoder.encode("1");
   invoked: -> at demo.mockito.MockSettingsTest.verboseLogging(MockSettingsTest.java:89)
   has returned: "encoded1" (java.lang.String)

############ Logging method invocation #3 on mock/spy ########
passwordEncoder.encode("2");
   invoked: -> at demo.mockito.MockSettingsTest.verboseLogging(MockSettingsTest.java:90)
   has returned: "null"

请注意, 第一个记录的调用对应于在存根时调用encode()。这是与调用存根方法相对应的下一个调用。

其他设定

Mockito提供了更多设置, 可让你执行以下操作:

  • 通过使用withSettings()。serializable()启用模拟序列化。
  • 通过使用withSettings()。stubOnly()来关闭方法调用的记录以节省内存(这将使验证变得不可能)。
  • 通过使用withSettings()。useConstructor()创建模拟的实例时, 使用模拟的构造函数。模拟内部非静态类时, 请添加一个externalInstance()设置, 如下所示:withSettings()。useConstructor()。outerInstance(outerObject)。

如果你需要使用自定义设置(例如自定义名称)创建间谍, 请使用spiedInstance()设置, 以便Mockito在你提供的实例上创建间谍, 如下所示:

UserService userService = new UserService(
       mock(UserRepository.class), mock(PasswordEncoder.class));
UserService userServiceMock = mock(
       UserService.class, withSettings().spiedInstance(userService).name("coolService"));

指定间谍实例后, Mockito将创建一个新实例, 并使用原始对象中的值填充其非静态字段。这就是使用返回的实例很重要的原因:只能存根和验证其方法调用。

请注意, 创建间谍程序时, 基本上是在创建一个调用真实方法的模拟程序:

// creating a spy this way...
spy(userService);
// ... is a shorthand for
mock(UserService.class, withSettings()
            .spiedInstance(userService)
            .defaultAnswer(CALLS_REAL_METHODS));

当Mockito味道不好时

是我们的不良习惯, 而不是Mockito, 使我们的测试变得复杂且难以维护。例如, 你可能会觉得有必要模拟一切。这种想法导致测试模拟而不是生产代码。由于第三方API的潜在更改可能会破坏测试, 因此嘲笑第三方API也可能很危险。

尽管不良品味只是一个感知问题, 但Mockito提供了一些有争议的功能, 这些功能可能会使你的测试难以维护。有时, 存根并不是一件容易的事, 或者滥用依赖项注入会使每个测试的模拟重建变得困难, 不合理或效率低下。

清除调用

Mockito允许清除模拟的调用, 同时保留存根, 如下所示:

PasswordEncoder passwordEncoder = mock(PasswordEncoder.class);
UserRepository userRepository = mock(UserRepository.class);
// use mocks
passwordEncoder.encode(null);
userRepository.findById(null);
// clear
clearInvocations(passwordEncoder, userRepository);
// succeeds because invocations were cleared
verifyZeroInteractions(passwordEncoder, userRepository);

仅当重新创建模拟会导致大量开销或依赖项注入框架提供已配置的模拟并且存根不平凡时, 才诉诸清除调用。

重置模拟

使用reset()重置模拟是另一个有争议的功能, 应在极少数情况下使用, 例如当模拟是由容器注入的, 而你无法为每个测试重新创建模拟时。

过度使用验证

另一个坏习惯是尝试用Mockito的verify()替换每个断言。重要的是要清楚地了解要测试的内容:可以使用verify()检查协作者之间的交互, 同时确认已执行操作的可观察结果是否由断言完成。

Mockito与心灵框架有关

使用Mockito不仅是添加另一个依赖项的问题, 还需要在删除大量样板的同时更改对单元测试的看法。

通过多个模拟界面, 侦听调用, 匹配器和参数捕获器, 我们已经了解到Mockito如何使你的测试更简洁, 更易于理解, 但是像其他任何工具一样, 必须适当使用它才能有用。现在, 有了Mockito内部工作知识, 你就可以将单元测试提高到一个新的水平。

赞(0)
未经允许不得转载:srcmini » 日常Mockito的单元测试从业人员指南

评论 抢沙发

评论前必须登录!