作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
伊万·巴甫洛夫的头像

By 伊凡巴甫洛夫

Ivan拥有后端和前端开发经验. 他曾为银行、医疗机构和城市管理部门开发软件.

Years of Experience

17

<但ton aria-label="分享" class="_2foqyr9I">分享
分享

编者注:本文由我们的编辑团队于2023年1月6日更新. 它已被修改,以包括最近的来源,并与我们目前的编辑标准保持一致.

单元测试 在敏捷时代已经成为强制性的吗, 并且有许多工具可以帮助进行自动化测试. 其中一个工具就是5, 一个允许您创建和配置用于测试的模拟对象的开源框架.

In 这 article, 我们将介绍创建和配置模拟,并使用它们来验证被测试系统的预期行为. 我们还探讨了5的内部结构,以便更好地理解它的设计和注意事项. 我们使用 JUnit 作为一个 单元测试框架, 但是因为5没有绑定到JUnit, 即使您使用的是不同的框架,也可以按照下面的步骤进行操作.

How Does 5 Work?

单元测试 是否设计用于测试特定类或方法的行为,而不依赖于它们的依赖项的行为. 因为我们测试的是最小的代码“单元”, 我们不需要使用这些依赖的实际实现. 此外, 在测试不同的行为时,我们使用这些依赖的略微不同的实现. 一个传统的, 这里的著名方法是创建适合给定场景的特定于“存根”的接口实现. 这样的实现通常具有硬编码的逻辑. A stub is a kind of test double; o的r kinds of doubles include fakes, 模拟s, spies, dummies, etc.

我们只关注两种类型的测试副本, “模拟s” 和 “spies,,因为这些都是5经常使用的.

What Is a Mock?

In unit testing, 模拟是一个创建的对象,它以受控的方式实现真实子系统的行为. 简而言之,模拟被用作依赖项的替代品.

与5, you create a 模拟, 告诉5在特定方法被调用时该做什么, 然后在测试中使用模拟实例而不是真实实例. After 的 test, 您可以查询模拟以查看调用了哪些特定的方法,或者以改变状态的形式检查副作用.

默认情况下,5为模拟的每个方法提供实现.

什么是间谍?

间谍是5创建的另一种类型的测试替身. 与创建模拟不同,创建间谍需要监视一个实例. 默认情况下, 间谍将所有方法调用委托给真实对象,并记录调用了什么方法以及使用了什么参数. 这就是它成为间谍的原因:它在监视一个真实的物体.

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

How Is 5 Set Up?

如今,获得5很容易. 如果你用的是Gradle,在你的构建脚本中添加这一行就可以了:

testCompile "org.5: 5−核心:2.7.7"

对于像我这样仍然喜欢Maven的人,只需将5添加到您的依赖项中:

 
    org.5 
    5-core 
    2.7.7 
    test 

当然,世界比Maven和Gradle要广阔得多. 您可以自由地使用任何项目管理工具从文件中获取5 jar构件 Maven中央存储库.

5核心功能

让我们将模拟和间谍的概念定义转换为具体的代码函数,以便在模拟单元测试中使用.

一个简单的演示

在讨论代码示例之前,让我们先看一个简单的演示,我们可以为它编写测试. Suppose we have a UserRepository 使用单个方法的接口,通过其标识符查找用户. 我们还有一个密码编码器的概念,可以将明文密码转换为密码散列. 这两个 UserRepositoryPasswordEncoder 的依赖关系(也称为协作者)是 UserService 通过构造函数注入. 下面是我们的演示代码:

UserRepository:

公共接口UserRepository
   用户findById(字符串id);
}

用户:

public class User {

   private String id;
   私字符串passwordHash;
   启用私有布尔值;

   公共用户(字符串id,字符串passwordHash,布尔启用){
       这.Id = Id;
       这.passwordHash = passwordHash;
       这.enabled = enabled;
   }
   // ...
}

PasswordEncoder:

公共接口PasswordEncoder
   字符串编码(字符串密码);
}

UserService:

UserService {

   private final UserRepository;
   private final PasswordEncoder;

   public UserService(UserRepository, UserRepository, PasswordEncoder) {
       这.用户Repository = 用户Repository;
       这.passwordEncoder = passwordEncoder;
   }

   公共布尔isValidUser(字符串id,字符串密码){
       用户User = 用户Repository.findById (id);
       返回isEnabledUser(用户) && isValidPassword(用户、密码);
   }

   private 布尔 isEnabledUser(用户用户){
       返回用户 !=零 && 用户.isEnabled ();
   }

   private 布尔 isValidPassword(用户用户,字符串密码){
       字符串encodedPassword = passwordEncoder.encode(password);
       返回encodedPassword.=(用户.getPasswordHash());
   }
}

在本文中讨论各种测试策略时,我们将参考我们的 简单的演示.

的5 模拟 方法

创建模拟就像调用静态对象一样简单 5.模拟() 方法:

import static org.5.5.*;
// ...
PasswordEncoder = 模拟(PasswordEncoder ..类);

注意5的静态导入. 对于本文的其余部分,我们隐式地认为添加了这个导入.

导入之后,我们进行模拟 PasswordEncoder, an 接口. 5不仅模拟接口,还模拟抽象类和具体的非最终类. 开箱即用,5最初不能模拟final类和final或静态方法,但是 MockMaker 插件现在可用.

还要注意,方法 = ()hashCode () cannot be 模拟ed.

的5 间谍 方法

要创建一个间谍,需要调用5的静态方法 间谍() 并传递给它一个监视实例. 调用返回对象的方法将调用真正的方法,除非这些方法是存根的. 这些电话被记录下来,这些电话的事实可以得到核实(见对 验证()). Let’s make a 间谍:

DecimalFormat = 间谍(new DecimalFormat());
assert平等的(“42”,decimalFormat.format(42L));

创建一个间谍和创建一个模拟没有太大的区别. 所有用于配置模拟的5方法也适用于配置间谍.

与模拟相比,间谍很少被使用, 但是您可能会发现它们对于测试无法重构的遗留代码很有用, 哪里的测试需要部分模拟. 在这些情况下,您可以创建一个间谍并存根它的一些方法来获得您想要的行为.

Default Return Values

调用 模拟(PasswordEncoder.类) 的实例。 PasswordEncoder. 我们甚至可以调用它的方法,但是它们会返回什么呢? 默认情况下,模拟的所有方法返回“未初始化”或“空”值.g., 数字类型为零(包括基本类型和盒装类型), false for 布尔s, 大多数其他类型都为空.

考虑下面的接口:

接口 Demo {
   int getInt ();
   Integer getInteger ();
   double getDouble();
   布尔 getBoolean();
   String getObject();
   Collection getCollection();
   String[] getArray();
   Stream getStream ();
   Optional getOptional();
}

现在考虑下面的代码片段, 它给出了一个关于从模拟方法中期望得到的默认值的想法:

Demo 演示 = 模拟(Demo.类);
assertEquals(0, 演示.getInt ());
assertEquals(0, 演示.getInteger ().intValue ());
assertEquals(0d, 演示.getDouble(), 0d);
assertFalse(演示.getBoolean());
assertNull(演示.getObject());
assert平等的(集合.emptyList(), 演示.getCollection());
assertNull(演示.getArray ());
assertEquals(0L, 演示.getStream ().count ());
assertFalse(演示.getOptional().isPresent());

测试:存根方法

新鲜的、未更改的模拟仅在极少数情况下有用. 通常, 我们希望配置模拟,并定义在调用模拟的特定方法时应该做什么. This 被称为 存根.

使用5 然后Return

5提供了两种存根方式. The 第一个 way is “ 这个方法叫做, 然后 做某事.这一策略使用了5的策略 然后Return 电话:

当(passwordEncoder.编码(" 1 ")).然后Return ("a");

用简单的英语说:“什么时候 passwordEncoder.编码(“1”) 被称为, return an a.”

第二种存根方式读起来更像是“在使用以下参数调用模拟的方法时做一些事情”.这种存根的方式很难读懂,因为原因是在最后指定的. 考虑:

doReturn(“”).当(passwordEncoder).编码(" 1 ");

使用这种存根方法的代码片段将是:“返回 apasswordEncoder’s 编码() 方法使用参数来调用 1.”

首选第一种方法,因为它类型安全且可读性更好. 在极少数情况下,你可能会被迫使用第二种方法.g.,因为调用一个真正的间谍方法可能会产生不必要的副作用.

让我们简要探讨一下5提供的存根方法. 我们将在示例中包括两种存根方法.

Returning Values

5的 然后Return or doReturn () 用于指定方法调用时要返回的值.

// "当这个方法被调用时,做一些事情"
当(passwordEncoder.编码(" 1 ")).然后Return ("a");


// "使用以下参数调用模拟的方法时执行一些操作"
doReturn(“”).当(passwordEncoder).编码(" 1 ");

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

/ /当
当(passwordEncoder.编码(" 1 ")).然后Return ("a", "b");

//do
doReturn("a", "b").当(passwordEncoder).编码(" 1 ");

使用下面的代码片段可以实现相同的结果:

当(passwordEncoder.编码(" 1 "))
       .然后Return ("a")
       .然后Return ("b");

这种模式还可以与其他存根方法一起使用,以定义连续调用的结果.

返回自定义响应

然后(), an alias to 然后回答 (), do回答 () 实现同样的事情,即设置一个自定义答案,以便在调用方法时返回:

/ / 然后回答
当(passwordEncoder.编码(" 1 ")).然后回答 (
       invocation -> invocation.getArgument(0) + "!");


/ / do回答
do回答(invocation -> invocation.getArgument(0) + "!")
       .当(passwordEncoder).编码(" 1 ");

The only argument 然后回答 () 的实现 回答 接口. 它有一个参数为type的方法 InvocationOnMock.

你也可以在方法调用后抛出异常:

当(passwordEncoder.编码(" 1 ")).然后回答 (invocation -> {
   抛出新的IllegalArgumentException();
});

或者,你可以调用类的真实方法(不适用于接口):

Date 模拟 = 模拟(Date.类);
do回答 (InvocationOnMock:: callReal方法).当(模拟).凝固时间(42);
do回答 (InvocationOnMock:: callReal方法).当(模拟).取得时间();
模拟.凝固时间(42);
assertEquals(42, 模拟.取得时间());

如果你认为它看起来很笨重,你是对的. 5 provides 然后CallReal方法()然后Throw () 来简化测试的这一方面.

调用 Real 方法s

As 的 names suggest, 然后CallReal方法()doCallReal方法() 在模拟对象上调用real方法:

Date 模拟 = 模拟(Date.类);
当(模拟.取得时间()).然后CallReal方法();
doCallReal方法().当(模拟).凝固时间(42);
模拟.凝固时间(42);
assertEquals(42, 模拟.取得时间());

在部分模拟上调用真正的方法可能很有用, 但是要确保被调用的方法没有不必要的副作用,并且不依赖于对象状态. 如果是这样,间谍可能比替身更合适.

如果您创建接口的模拟,并尝试配置存根以调用真正的方法, 5将抛出一个异常,并提供非常有用的消息. 考虑下面的代码片段:

当(passwordEncoder.编码(" 1 ")).然后CallReal方法();

5将失败并给出以下消息:

不能在java对象上调用抽象实方法!
只有在模拟非抽象方法时才能调用真正的方法.
  //correct example:
  当(模拟OfConcreteClass.nonAbstract方法()).然后CallReal方法();

感谢5开发人员提供了如此详尽的描述!

Throwing Exceptions

然后Throw ()doThrow () 配置一个模拟方法以抛出异常:

/ / 然后Throw
当(passwordEncoder.编码(" 1 ")).然后Throw(新IllegalArgumentException ());


/ / doThrow
doThrow(新IllegalArgumentException ()).当(passwordEncoder).编码(" 1 ");

5确保抛出的异常对于特定的存根方法是有效的,如果异常不在方法的检查异常列表中,则会发出警告. 考虑以下几点:

当(passwordEncoder.编码(" 1 ")).然后Throw(新IOException ());

它将导致错误:

org.5.异常.base.5Exception: 
已检查异常对该方法无效!
无效:java.io.IOException

如你所见,5检测到了这一点 编码() can’t throw an IOException.

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

/ / 然后Throw
当(passwordEncoder.编码(" 1 ")).然后Throw (IllegalArgumentException.类);


/ / doThrow
doThrow (IllegalArgumentException.类).当(passwordEncoder).编码(" 1 ");

也就是说, 5不能像验证异常实例那样验证异常类, 因此,您必须遵守纪律,不要传递非法的类对象. 例如,下面的语句将抛出 IOException 虽然 编码() 不会引发检查异常:

当(passwordEncoder.编码(" 1 ")).然后Throw(IOException.类);
passwordEncoder.编码(" 1 ");

用默认方法模拟接口

在为接口创建模拟时,值得注意的是, 5模拟该接口的所有方法. 自Java 8以来,接口可以包含默认方法和抽象方法. 这些方法也是模拟的,因此需要注意使它们充当默认方法.

考虑下面的例子:

AnInterface {
   默认布尔值isTrue() {
       返回真正的;
   }
}
AnInterface 模拟 = 模拟(AnInterface.类);
assertFalse(模拟.isTrue ());

In 这 example, assertFalse () 会成功的. 如果这不是你所期望的,确保你已经让5调用了真正的方法:

AnInterface 模拟 = 模拟(AnInterface.类);
当(模拟.isTrue ()).然后CallReal方法();
assertTrue(模拟.isTrue ());

测试:参数匹配器

在前面的小节中,我们用精确的值作为参数配置了模拟方法. 在这些情况下,5只是打电话 = () 在内部检查期望值是否等于实际值.

不过,有时候我们事先并不知道这些值.

也许我们只是不关心作为参数传递的实际值, 或者我们想为更大范围的值定义一个反应. 所有这些场景(以及其他场景)都可以通过参数匹配器来解决. 这个想法很简单:不是提供一个精确的值, 为5提供一个参数匹配器来匹配方法参数.

考虑下面的代码片段:

当(passwordEncoder.encode(anyString ())).然后Return ("exact");
passwordEncoder assert平等的(“精确”.编码(" 1 "));
passwordEncoder assert平等的(“精确”.encode("abc"));

您可以看到,无论我们传递什么值,结果都是相同的 编码() because we used 的 anyString () 参数匹配器在第一行. 如果我们用通俗易懂的英语重写这一行, 当要求密码编码器编码任何字符串时,它将是“, 然后返回字符串' exact '.’”

5要求您通过匹配器提供所有参数 or by exact values. 如果一个方法有多于一个参数而你只想对它的一些参数使用参数匹配器, it can’t be done. 你不能这样写代码:

抽象类{
   public abstract 布尔 call(String s, int i);
}
AClass =模拟(AClass.类);
//This doesn’t work.
当(模拟.call("a", anyInt ())).然后Return (真正的);

要修复此错误,必须替换最后一行以包含 eq argument 匹配器 for a, as follows:

当(模拟.调用(eq(“a”),anyInt ())).然后Return (真正的);

Here we’ve used 的 eq()anyInt () 参数匹配器,但还有许多其他可用的. 有关参数匹配器的完整列表,请参阅 org.5.ArgumentMatchers class.

重要的是要注意,不能在验证或存根之外使用参数匹配器. 例如,您不能有以下内容:

//This won’t work.
字符串orMatcher = or(eq(“”), endsWith("b"));
验证(模拟).encode(orMatcher);

5将检测到放错位置的参数匹配器并抛出 InvalidUseOfMatchersException. 使用参数匹配器进行验证应该这样做:

验证(模拟).编码(或(eq(“a”),endsWith (" b ")));

参数匹配器也不能用作返回值. 5 can’t return anyString () or any-whatever; an exact value is required 当 存根 calls.

Custom Matchers

当您需要提供5中尚未提供的一些匹配逻辑时,自定义匹配器可以提供帮助. 创建自定义匹配器的决定不应该轻易做出,因为需要以一种重要的方式匹配参数,这要么表明存在设计问题,要么表明测试过于复杂.

像这样, 是否可以通过使用一些宽松的参数匹配器来简化测试是值得检查的 isNull ()零able () 在编写自定义匹配器之前. 如果您仍然觉得需要编写参数匹配器, 5提供了一系列方法来实现这一点.

考虑下面的例子:

FileFilter FileFilter = 模拟(FileFilter.类);
ArgumentMatcher hasLuck = file -> file.getName ().endsWith("luck");
当(fileFilter.接受(argThat (hasLuck))).然后Return (真正的);
assertFalse (fileFilter.接受(新文件(" /值得")));
assertTrue(fileFilter.接受(新文件(“/应该/运气”)));

Here we create 的 hasLuck 参数匹配器和使用 argThat () 将匹配器作为参数传递给模拟方法,将其存根设置为返回 真正的 如果文件名以“luck .”结尾.” You can treat ArgumentMatcher 作为一个函数接口,并使用lambda创建它的实例(这就是我们在示例中所做的). 不那么简洁的语法看起来像这样:

ArgumentMatcher hasLuck = new ArgumentMatcher() {
   @Override
   公共布尔匹配(文件文件){
       返回文件.getName ().endsWith("luck");
   }
};

如果您需要创建与基本类型一起工作的参数匹配器, 还有一些其他的方法 org.5.ArgumentMatchers:

  • charThat(ArgumentMatcher 匹配器)
  • 布尔That(ArgumentMatcher 匹配器)
  • byteThat(ArgumentMatcher 匹配器)
  • shortThat(ArgumentMatcher 匹配器)
  • intThat(ArgumentMatcher 匹配器)
  • longThat(ArgumentMatcher 匹配器)
  • floatThat(ArgumentMatcher 匹配器)
  • doubleThat(ArgumentMatcher 匹配器)

Combining Matchers

It’s not always worth creating a custom argument 匹配器 当 a condition is too complicated to be h和led with basic 匹配器; sometimes combining 匹配器 will do 的 trick. 5提供参数匹配器来实现常见的逻辑操作(“not,”“和," "或"),用于匹配基本类型和非基本类型的参数匹配器. 这些匹配器是作为静态方法实现的 org.5.AdditionalMatchers class.

考虑下面的例子:

当(passwordEncoder.编码(或(eq(“1”),包含(a)))).然后Return ("ok");
passwordEncoder assert平等的(“ok”.编码(" 1 "));
passwordEncoder assert平等的(“ok”.encode("123abc"));
assertNull (passwordEncoder.encode("123"));

这里我们结合了两个参数匹配器的结果: eq("1")包含(“”). The final expression, 或(eq(“1”),包含(a)),可以解释为“参数字符串必须是” 平等的 到“1”或 包含 “a”.

列表中列出了一些不太常见的匹配器 org.5.AdditionalMatchers 类, such as 组(), leq (), gt(), lt(),它们是适用于基本值和实例的值比较 java.朗.类似的.

测试:验证行为

一旦使用了假人或间谍,我们就可以 验证 这种特定的互动发生了. 从字面上看,我们在说,“嘿,5,确保这个方法是用这些参数调用的.”

考虑以下人为的例子:

PasswordEncoder = 模拟(PasswordEncoder ..类);
当(passwordEncoder.编码(“a”)).然后Return ("1");
passwordEncoder.编码(“a”);
验证(passwordEncoder).编码(“a”);

这里我们设置了一个模拟,并将其命名为its 编码() 方法. 最后一行验证了模拟的 编码() 方法使用特定参数值调用 a. Please note that 验证ing a stubbed invocation is redundant; 的 purpose of 的 previous snippet is to show 的 idea of doing verification after some interactions happened.

如果我们把最后一行改成不同的参数,比如, b-之前的测试会失败,5会抱怨实际调用有不同的参数(b 而不是预期的 a).

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

验证(passwordEncoder).encode(anyString ());

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

//验证调用的确切次数
验证(passwordEncoder,乘以(42)).encode(anyString ());

//验证至少有一个调用
验证(passwordEncoder atLeastOnce ()).encode(anyString ());

//验证至少有5个调用
验证(passwordEncoder至少(5)).encode(anyString ());

//验证最大调用数
验证(passwordEncoder atMost (5)).encode(anyString ());

//验证它是唯一的调用
//没有更多未经验证的交互
验证(passwordEncoder,只有()).encode(anyString ());

//验证没有调用
验证(passwordEncoder,从不()).encode(anyString ());

很少使用的特征 验证() 它是否能够在超时时失败,这主要用于测试并发代码. 例如,如果密码编码器在另一个线程中并发地调用 验证(),我们可以编写如下测试:

usePasswordEncoderInO的rThread ();
验证(passwordEncoder超时(500)).编码(“a”);

这个测试将成功,如果 编码() 在500毫秒或更短时间内调用并完成. 如果需要等待指定的完整时间段,则使用 后() 而不是 超时():

后,验证(passwordEncoder (500)).编码(“a”);

其他验证模式(* (), 至少()等)可以与…结合 超时()后() 做更复杂的测试:

//在500毫秒内调用编码() 3次后立即通过
验证(passwordEncoder超时(500).(3)).编码(“a”);

除了 * (),支持的验证方式包括 只有(), 至少(), atLeastOnce () (作为一个n alias to 至少(1)).

5还允许您验证一组模拟中的调用顺序. 这不是一个经常使用的特性,但如果调用的顺序很重要,它可能很有用. 考虑下面的例子:

PasswordEncoder首先= 模拟(PasswordEncoder.类);
PasswordEncoder秒= 模拟(PasswordEncoder.类);
// simulate calls
第一个.encode("f1");
第二个.encode("s1");
第一个.encode("f2");
// 验证 call order
InOrder = InOrder(第一,第二);
有条不紊地进行.验证(第一个).encode("f1");
有条不紊地进行.验证(第二个).encode("s1");
有条不紊地进行.验证(第一个).encode("f2");

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

也可以使用 验证ZeroInteractions (). 此方法接受一个或多个模拟作为参数,如果调用传入模拟的任何方法,该方法将失败.

值得一提的是 验证NoMoreInteractions () 方法, 因为它将模拟作为参数,并可用于检查对这些模拟的每个调用是否已验证.

Capturing Arguments

除了验证是否使用特定参数调用方法之外, 5允许您捕获这些参数,以便以后可以在它们上运行自定义断言. In o的r words, 您可以验证是否调用了某个方法, 并接收调用它时使用的参数值.

让我们创建一个模拟 PasswordEncoder,叫 编码(),捕获参数,并检查其值:

PasswordEncoder = 模拟(PasswordEncoder ..类);
passwordEncoder.encode("password");
ArgumentCaptor passwordCaptor = ArgumentCaptor.forClass(String.类);
验证(passwordEncoder).encode(passwordCaptor.捕获());
passwordCaptor assert平等的(“密码”.getValue ());

如你所见,我们通过了 passwordCaptor.捕获() 作为一个n argument of 编码() for verification; 这 内部ly creates an argument 匹配器 that saves 的 argument. 然后,我们用 passwordCaptor.getValue () 和 inspect it with assertEquals().

如果我们需要在多个调用中捕获一个参数, ArgumentCaptor 让您检索所有值 getAllValues():

PasswordEncoder = 模拟(PasswordEncoder ..类);
passwordEncoder.encode("password1");
passwordEncoder.encode("password2");
passwordEncoder.encode("password3");
ArgumentCaptor passwordCaptor = ArgumentCaptor.forClass(String.类);
(3)验证(passwordEncoder倍).encode(passwordCaptor.捕获());
assertEquals(Arrays.asList("password1", "password2", "password3"),
             passwordCaptor.getAllValues());

同样的技术也可以用于捕获可变大小的方法参数(也称为varargs).

测试:简单的模拟示例

现在我们对测试功能有了更多的了解, 是时候回到Java 5演示了. Let’s write 的 isValidUser 方法测试. 它可能是这样的:

公共类UserServiceTest {

   private static final String PASSWORD = " PASSWORD ";

   private static final User ENABLED_USER =
           new User(" User id", "hash", 真正的);

   private static final User DISABLED_USER =
           new User("禁用用户id", "禁用用户密码哈希值",false);
  
   private UserRepository;
   private PasswordEncoder;
   私有UserService;

   @Before
   public void setup() {
       用户Repository = createUserRepository();
       passwordEncoder = createPasswordEncoder();
       用户Service = new 用户Service (用户Repository, passwordEncoder);
   }

   @Test
   公共void shouldBeValidForValidCredentials() {
       布尔 用户IsValid = 用户Service.isValidUser (ENABLED_USER.getId(), PASSWORD);
       assertTrue (用户IsValid);

       //使用用户Repository查找id="用户 id"的用户
       验证(用户Repository).findById(ENABLED_USER.getId ());

       // passwordEncoder必须用于计算“password”的哈希值
       验证(passwordEncoder).encode(PASSWORD);
   }

   @Test
   public void shouldbeinvalidforinvaliddid () {
       布尔 用户IsValid = 用户Service.isValidUser("无效id", PASSWORD);
       assertFalse (用户IsValid);

       InOrder = InOrder (用户Repository, passwordEncoder);
       有条不紊地进行.验证(用户Repository).findById(“无效id”);
       有条不紊地进行.验证(passwordEncoder,从不()).encode(anyString ());
   }

   @Test
   公共void shouldBeInvalidForInvalidPassword() {
       布尔 用户IsValid = 用户Service.isValidUser (ENABLED_USER.getId(), "invalid");
       assertFalse (用户IsValid);

       ArgumentCaptor passwordCaptor = ArgumentCaptor.forClass(String.类);
       验证(passwordEncoder).encode(passwordCaptor.捕获());
       passwordCaptor assert平等的(“无效”.getValue ());
   }

   @Test
   公共void shouldBeInvalidForDisabledUser() {
       布尔 用户IsValid = 用户Service.isValidUser (DISABLED_USER.getId(), PASSWORD);
       assertFalse (用户IsValid);

       验证(用户Repository).findById (DISABLED_USER.getId ());
       验证ZeroInteractions (passwordEncoder);
   }

   createPasswordEncoder() {
       PasswordEncoder . 模拟 = 模拟(PasswordEncoder ..类);
       当(模拟.encode(anyString ())).然后Return ("任意密码哈希值");
       当(模拟.encode(PASSWORD)).然后Return (ENABLED_USER.getPasswordHash());
       返回模拟;
   }

   private UserRepository createUserRepository() {
       UserRepository 模拟 = 模拟(UserRepository.类);
       当(模拟.findById(ENABLED_USER.getId ())).然后Return (ENABLED_USER);
       当(模拟.findById (DISABLED_USER.getId ())).然后Return (DISABLED_USER);
       返回模拟;
   }
}

测试:高级5使用

5提供了一个可读的, convenient API, 但是,让我们来探索一下它的一些内部工作原理,以了解它的局限性并避免奇怪的错误.

Ordering Calls

让我们来看看在运行以下代码片段时,5内部发生了什么:

// 1:创建
PasswordEncoder . 模拟 = 模拟(PasswordEncoder ..类);
// 2: stub
当(模拟.编码(“a”)).然后Return ("1");
// 3:行动
模拟.编码(“a”);
// 4:验证
验证(模拟).编码(或(eq(“a”),endsWith (" b ")));

显然,第一行创建了一个模拟. 5使用 ByteBuddy 创建给定类的子类. 新的类对象有一个生成的名称,如 演示.5.PasswordEncoder 5Mock 1953422997美元; its = () 将作为身份检查,和 hashCode () 会返回一个标识哈希码吗. 类生成并加载后,将使用 Objenesis.

让我们看看下一行:

当(模拟.编码(“a”)).然后Return ("1");

排序很重要:这里执行的第一条语句是 模拟.编码(“”), which will invoke 编码() 在模拟上使用默认返回值为 . 所以,我们真的是在擦肩而过 作为一个n argument of 当(). 5并不关心传递给的确切值是什么 当() 因为它在调用模拟方法时将有关该方法调用的信息存储在所谓的“持续存根”中. 等会儿,我们打电话的时候 当(), 5提取正在进行的存根对象,并将其作为结果返回 当(). 然后我们打电话 然后Return (“1”) 在返回的正在存根对象上.

The third line, 模拟.编码(“a”); 很简单:我们调用存根方法. 在内部, 5 saves 这 invocation for fur的r verification 和 returns 的 stubbed invocation answer; in our case, it’s 的 string 1.

In 的 fourth line (验证(模拟).编码(或(eq(“a”),endsWith (" b ")));),我们要求5验证是否有调用 编码() 有了这些具体的论点.

验证() 首先执行,这将5的内部状态转换为验证模式. 重要的是要理解5将其状态保存在 ThreadLocal. 这使得实现良好的语法成为可能,但, on 的 o的r h和, 如果框架使用不当(如果您试图在验证或存根之外使用参数匹配器),可能会导致奇怪的行为, 例如).

那么5是如何创建一个 or 匹配器? 首先, eq(“”) 被称为, an = Matcher被添加到匹配器堆栈中. 第二, endsWith("b") 被称为, an endsWith 匹配器被添加到堆栈中. 最后的, or(零, 零) 调用:它使用从堆栈弹出的两个匹配器,创建 or Matcher,并将其推入堆栈. 最后, 编码() 被称为. 然后,5验证该方法是否调用了预期的次数,并使用了预期的参数.

而参数匹配器不能被提取到变量(因为它们改变了调用顺序), 它们可以被提取到方法中. 这保留了调用顺序,并使堆栈保持在正确的状态:

验证(模拟).编码(matchCondition ());
// ...
字符串匹配条件(){
   return or(eq(“”), endsWith("b"));
}

更改默认答案

In previous sections, 我们以这样一种方式创建模拟:当调用任何模拟方法时, 它们返回一个“空”值. 此行为是可配置的. 您甚至可以提供自己的实现 org.5.存根.回答 如果5提供的不合适, 但是,当单元测试变得过于复杂时,这可能表明出了问题. 当你有疑问的时候,尽量做到简单.

让我们来看看5提供的预定义默认答案:

RETURNS_DEFAULTSThe default strategy; it isn’t worth mentioning explicitly 当 setting up a 模拟.
CALLS_REAL_METHODS使未存根的调用调用真正的方法.
RETURNS_SMART_NULLS避免了 NullPointerException 通过返回 SmartNull 而不是 使用由未存根方法调用返回的对象时. 你还是会不及格 NullPointerException,但 SmartNull 为您提供了一个更好的堆栈跟踪,其中包含调用未存根方法的行. 这使得它值得拥有 RETURNS_SMART_NULLS 是5的默认答案.
RETURNS_MOCKS首先尝试返回普通的“空”值,然后在可能的情况下进行模拟 否则. 空性的标准与我们之前看到的稍有不同:不返回 对于字符串和数组,用创建的模拟 RETURNS_MOCKS 分别返回空字符串和空数组.
RETURNS_SELF用于模拟构建器. With 这 setting, 如果调用的方法返回与被模拟类的类(或超类)类型相等的对象,则模拟将返回自身的实例.
RETURNS_DEEP_STUBSGoes 深的。er than RETURNS_MOCKS 并创建能够从模拟中返回模拟的模拟,等等. In contrast to RETURNS_MOCKS,空规则是默认的 RETURNS_DEEP_STUBS, so it returns 对于字符串和数组:

接口 We { we(); } 接口 { So (); } 接口 So { 深的。 so(); } 接口 深的。 { 布尔 深的。(); } // ... We 模拟 = 模拟(We.class, 5.RETURNS_DEEP_STUBS); 当(模拟.we().是().so().深()).然后Return (真正的); assertTrue(模拟.we().是().so().深());

Naming a Mock

5允许您为模拟命名, 如果您在测试中有很多模拟并且需要区分它们,那么这是一个有用的特性. 也就是说,需要命名模拟可能是糟糕设计的征兆. 考虑以下几点:

PasswordEncoder robustPasswordEncoder = 模拟(PasswordEncoder.类);
PasswordEncoder = 模拟(PasswordEncoder.类);
验证(robustPasswordEncoder).encode(anyString ());

5将以错误消息进行投诉, 因为我们还没有正式命名模拟, 我们不知道引用的是哪个密码编码器:

需要但未启用的:
passwordEncoder.encode();

让我们通过在构造时传递一个字符串来命名模拟:

PasswordEncoder robustPasswordEncoder = 模拟(PasswordEncoder.类,“robustPasswordEncoder”);
PasswordEncoder = 模拟(PasswordEncoder.类,“weakPasswordEncoder”);
验证(robustPasswordEncoder).encode(anyString ());

现在错误消息更友好了,并且清楚地指向 robustPasswordEncoder:

需要但未启用的:
robustPasswordEncoder.encode();

实现多个模拟接口

有时,您可能希望创建实现多个接口的模拟. 5很容易做到这一点:

PasswordEncoder 模拟 = 模拟(
       PasswordEncoder.类, withSettings().extraInterfaces(List.类,地图.类));
assertTrue(模拟 instanceof List);
assertTrue(模拟 instanceof Map);

Listening Invocations

可以将模拟配置为在每次调用模拟的方法时调用调用侦听器. Inside 的 listener, 您可以了解调用是否产生了一个值,或者是否抛出了一个异常.

InvocationListener = new InvocationListener() {
   @Override
   public void reportInvocation(方法InvocationReport报告){
       如果报告.threwException()) {
           Throwable =报告.getThrowable();
           //使用throwable做一些事情
           throwable.printStackTrace();
       } else {
           返回值=报告.getReturnedValue();
           //处理返回值
           系统.出.println (returnedValue);
       }
   }
};
PasswordEncoder = 模拟(
       PasswordEncoder.类, withSettings().invocationListeners (invocationListener));
passwordEncoder.编码(" 1 ");

在本例中,我们将返回值或堆栈跟踪转储到系统输出流. 我们的实现与5的大致相同 org.5.内部.调试.VerboseMockInvocationLogger (但这是一个内部实现,不应该直接调用). 如果日志记录调用是侦听器唯一需要的特性, 那么5提供了一种更简洁的方式来表达你的意图 verboseLogging() 设置:

PasswordEncoder = 模拟(
       PasswordEncoder.类, withSettings().verboseLogging());

注意,即使在存根方法时,5也会调用侦听器. 考虑下面的例子:

PasswordEncoder = 模拟(
       PasswordEncoder.类, withSettings().verboseLogging());
//在编码()调用时调用侦听器
当(passwordEncoder.编码(" 1 ")).然后Return (“encoded1”);
passwordEncoder.编码(" 1 ");
passwordEncoder.编码(“2”);

这个代码片段将产生类似于下面的输出:

############ 日志记录方法调用# 1模拟/间谍# # # # # # # #
passwordEncoder.编码(" 1 ");
   invoked: -> at 演示.5.MockSettingsTest.verboseLogging (MockSettingsTest.java: 85)
   has returned: "零"

############ 日志记录方法调用# 2模拟/间谍# # # # # # # #
   stubbed: -> at 演示.5.MockSettingsTest.verboseLogging (MockSettingsTest.java: 85)
passwordEncoder.编码(" 1 ");
   invoked: -> at 演示.5.MockSettingsTest.verboseLogging (MockSettingsTest.java: 89)
   已返回:"encoded1" (java.朗.字符串)

############ 日志记录方法调用# 3模拟/间谍# # # # # # # #
passwordEncoder.编码(“2”);
   invoked: -> at 演示.5.MockSettingsTest.verboseLogging (MockSettingsTest.java: 90)
   has returned: "零"

注意,第一个记录的调用对应于调用 编码() while 存根 it. 它是下一个调用,对应于调用存根方法.

O的r Settings

5提供了更多的设置,让你做以下事情:

  • 通过使用启用模拟序列化 withSettings().serializable().
  • 关闭方法调用的记录以节省内存(这将使验证无法进行) withSettings().stubOnly ().
  • 创建模拟的实例时,使用模拟的构造函数 withSettings().useConstructor(). 在模拟内部非静态类时,添加 出erInstance() 设置: withSettings().useConstructor().出erInstance (出erObject).

如果需要创建具有自定义设置(例如自定义名称)的间谍,可以使用 spiedInstance() 这样5就会在你提供的实例上创建一个间谍:

UserService = new UserService(
       模拟(UserRepository.类),模拟(PasswordEncoder.类));
UserService 用户ServiceMock = 模拟(
       UserService.类,
       withSettings().spiedInstance (用户Service).name("coolService"));

指定监视实例时, 5将创建一个新实例,并用原始对象的值填充其非静态字段. 这就是为什么使用返回的实例很重要:只有它的方法调用可以被存根和验证.

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

//通过这种方式创建一个间谍...
间谍(用户Service);
// ... is a shorth和 for
模拟(UserService.类,
    withSettings()
            .spiedInstance (用户Service)
            .default回答 (CALLS_REAL_METHODS));

5的常见缺陷

我们的坏习惯使我们的测试变得复杂和不可维护,而不是5. 例如,你可能觉得有必要嘲笑一切. 这种想法导致测试模拟而不是测试生产代码. 模拟第三方API也可能是危险的,因为该API的潜在更改可能会破坏测试.

虽然坏品味是一种感知问题, 5提供了一些有争议的特性,这些特性会降低测试的可维护性. 有时存根不是微不足道的, 或者,滥用依赖注入会使为每个测试重新创建模拟变得困难, 不合理的, or inefficient.

Clearing Invocations

5允许清除模拟调用,同时保留存根:

PasswordEncoder = 模拟(PasswordEncoder ..类);
UserRepository = 模拟(UserRepository.类);
//使用模拟
passwordEncoder.编码(空);
用户Repository.findById(零);
/ /清楚
clearInvocations (passwordEncoder 用户Repository);
//成功,因为调用已被清除
验证ZeroInteractions (passwordEncoder 用户Repository);

只有在重新创建模拟会导致很大的开销,或者由依赖注入框架提供了已配置的模拟,并且存根很重要的情况下,才需要清除调用.

Resetting a Mock

Resetting a 模拟 with 重置() 是另一个有争议的功能,应该在极少数情况下使用吗, 例如,当容器注入模拟时,您不能为每个测试重新创建它.

Overusing Verify

另一个坏习惯是试图用5的断言代替每个断言 验证(). 清楚地了解被测试的内容是很重要的:合作者之间的互动可以通过 验证(),同时确认已执行操作的可观察结果是用断言完成的.

模仿是一种心态

使用5不仅仅是添加另一个依赖项; it requires changing how you think ab出 your unit tests while removing a lot of boilerplate.

使用多个模拟接口, listening invocations, 匹配器, argument captors, 我们已经看到5如何使您的测试更清晰、更容易理解,但, 像任何工具一样, 它必须正确使用才能有用. 现在你已经了解了5的内部运作, 您可以将单元测试提升到下一个层次.

了解基本知识

  • 5是用来做什么的?

    5用于创建和配置模拟对象.g.(模拟或间谍)在单元测试时.

  • 为什么模拟测试很重要?

    当在测试中不可能或不实际使用真实对象时,在测试中使用模拟非常重要. 模拟允许您创建一个对象,该对象以受控的方式实现真实子系统的行为.

  • 嘲笑的好处是什么?

    模拟提供了在单元测试中使用依赖的另一种选择,因为它们避免使用真实对象.

  • 嘲弄是一种好习惯吗??

    是的, 在不可能在测试中使用真实对象的情况下,深思熟虑且有节制地模拟是一种很好的实践. 建议尽可能使用模拟而不是间谍,以帮助确保您的类不会违反单一职责原则.

聘请Toptal这方面的专家.
现在雇佣
伊万·巴甫洛夫的头像
伊凡巴甫洛夫

位于 Hamburg, Germany

成员自 March 7, 2016

关于 的 author

Ivan拥有后端和前端开发经验. 他曾为银行、医疗机构和城市管理部门开发软件.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Years of Experience

17

世界级的文章,每周发一次.

<但ton class="_2bg14bwf _1Yko3Q2K _1SZtmxGM _2Yt-rhsd _1S6tHuDo" data-ga-category="bottom_sticky_bar" data-ga-event="cta_clicked" data-ga-label="Sign me up - Sticky - 0%" type="submit">帮我报名

订阅意味着同意我们的 privacy policy

世界级的文章,每周发一次.

<但ton class="_2bg14bwf _1Yko3Q2K _1SZtmxGM _2Yt-rhsd _1S6tHuDo" data-ga-category="bottom_sticky_bar" data-ga-event="cta_clicked" data-ga-label="Sign me up - Bottom - 0%" type="submit">帮我报名

订阅意味着同意我们的 privacy policy

Toptal 开发人员

Join 的 Toptal® 社区.