为什么在执行单元测试时没有调用我的模拟方法?

kni*_*ttl 3 java junit unit-testing mocking mockito

前言:

这个问题和答案旨在作为对由于误用 Mockito 或误解 Mockito 如何工作以及与用 Java 语言编写的单元测试交互而产生的大多数问题的规范答案。


我已经实现了一个应该进行单元测试的类。请注意,此处显示的代码只是一个虚拟实现,Random仅供说明之用。真实的代码将使用真实的依赖项,例如另一个服务或存储库。

public class MyClass {
  public String doWork() {
    final Random random = new Random(); // the `Random` class will be mocked in the test
    return Integer.toString(random.nextInt());
  }
}
Run Code Online (Sandbox Code Playgroud)

我想使用 Mockito 来模拟其他类,并编写了一个非常简单的 JUnit 测试。但是,我的类在测试中没有使用模拟:

public class MyTest {
  @Test
  public void test() {
    Mockito.mock(Random.class);
    final MyClass obj = new MyClass();
    Assertions.assertEquals("0", obj.doWork()); // JUnit 5
    // Assert.assertEquals("0", obj.doWork());  // JUnit 4
    // this fails, because the `Random` mock is not used :(
  }
}
Run Code Online (Sandbox Code Playgroud)

即使使用 (JUnit 4) 运行测试或使用(JUnit 5)MockitoJUnitRunner扩展并使用注释也无济于事;真正的实现仍然使用:MockitoExtension@Mock

@ExtendWith(MockitoExtension.class)   // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTest {
  @Mock
  private Random random;

  @Test
  public void test() {
    final MyClass obj = new MyClass();
    Assertions.assertEquals("0", obj.doWork()); // JUnit 5
    // Assert.assertEquals("0", obj.doWork());  // JUnit 4
    // `Random` mock is still not used :((
  }
}
Run Code Online (Sandbox Code Playgroud)

为什么没有使用模拟类,即使在测试我的类之前调用​​ Mockito 方法或使用 Mockito 扩展/运行器执行测试?


该问题的其他变体包括但不限于:

  • 我的模拟返回 null / 我的存根返回 null
  • 使用 Mockito 时出现 NullPointerException
  • 我的模拟在测试中为空
  • 我的模拟没有返回预期值/我的存根没有返回预期值
  • MockitothenReturn未获荣誉 / MockitothenAnswer未获荣誉
  • @InjectMocks不工作
  • @Mock不工作
  • Mockito.mock不工作
  • 我的类没有使用模拟/我的类没有使用存根
  • 我的测试仍然调用或执行模拟/存根类的实际实现

kni*_*ttl 11

TLDR:存在两个或多个不同的模拟实例。您的测试正在使用一个实例,而被测试的类正在使用另一个实例。或者您在类中根本没有使用模拟,因为您new在类中创建了对象。

\n
\n

问题概述(类与实例)

\n

模拟是实例(这就是为什么它们也被称为“模拟对象”)。调用Mockito.mock一个类将返回该类的模拟对象。它必须分配给一个变量,然后可以将其传递给相关方法或作为依赖项注入其他类。它不会修改类本身!想想看:如果这是真的,那么该类的所有实例都会以某种方式神奇地转换为模拟。这将使得无法模拟使用多个实例的类或 JDK 中的类,例如ListMap(一开始就不应该模拟,但这是一个不同的故事)。

\n

@Mock对于使用 Mockito 扩展/运行器进行注释也是如此:创建一个模拟对象的新实例@Mock,然后将其分配给用 注释的字段(或参数) 。该模拟对象仍然需要传递给正确的方法或作为依赖项注入。

\n

避免这种混乱的另一种方法是:new在 Java 中,将始终为对象分配内存,并初始化真实类的新实例。覆盖 的行为是不可能的new。即使是像 Mockito 这样聪明的框架也无法做到这一点。

\n
\n

解决方案

\n

\xc2\xbb但是我怎样才能模拟我的类呢?\xc2\xab 你会问。更改类的设计以使其可测试!每次您决定使用 时new,您就将自己委托给这个确切类型的实例。根据您的具体用例和要求,存在多种选项,包括但不限于:

\n
    \n
  1. 如果您可以更改方法的签名/接口,请将(模拟)实例作为方法参数传递。这要求实例在所有调用站点中都可用,但这可能并不总是可行。
  2. \n
  3. 如果无法更改方法的签名,请在构造函数中注入依赖项并将其存储在稍后由方法使用的字段中。
  4. \n
  5. 有时,实例只能在调用方法时创建,而不是在调用方法之前创建。在这种情况下,您可以引入另一个间接级别并使用称为抽象工厂模式的东西东西。然后,工厂对象将创建并返回依赖项的实例。工厂可以存在多种实现:一种返回真正的依赖项,另一种返回测试替身,例如模拟。
  6. \n
\n

下面您将找到每个选项的示例实现(带或不带 Mockito 运行器/扩展):

\n

更改方法签名

\n
public class MyClass {\n  public String doWork(final Random random) {\n    return Integer.toString(random.nextInt());\n  }\n}\n\npublic class MyTest {\n  @Test\n  public void test() {\n    final Random mockedRandom = Mockito.mock(Random.class);\n    final MyClass obj = new MyClass();\n    Assertions.assertEquals("0", obj.doWork(mockedRandom)); // JUnit 5\n    // Assert.assertEquals("0", obj.doWork(mockedRandom));  // JUnit 4\n  }\n}\n\n@ExtendWith(MockitoExtension.class)   // JUnit 5\n// @RunWith(MockitoJUnitRunner.class) // JUnit 4\npublic class MyTestAnnotated {\n  @Mock\n  private Random random;\n\n  @Test\n  public void test() {\n    final MyClass obj = new MyClass();\n    Assertions.assertEquals("0", obj.doWork(random)); // JUnit 5\n    // Assert.assertEquals("0", obj.doWork(random));  // JUnit 4\n  }\n}\n
Run Code Online (Sandbox Code Playgroud)\n

构造函数依赖注入

\n
public class MyClass {\n  private final Random random;\n\n  public MyClass(final Random random) {\n    this.random = random;\n  }\n\n  // optional: make it easy to create "production" instances (although I wouldn\'t recommend this)\n  public MyClass() {\n    this(new Random());\n  }\n\n  public String doWork() {\n    return Integer.toString(random.nextInt());\n  }\n}\n\npublic class MyTest {\n  @Test\n  public void test() {\n    final Random mockedRandom = Mockito.mock(Random.class);\n    final MyClass obj = new MyClass(mockedRandom);\n    // or just obj = new MyClass(Mockito.mock(Random.class));\n    Assertions.assertEquals("0", obj.doWork()); // JUnit 5\n    // Assert.assertEquals("0", obj.doWork());  // JUnit 4\n  }\n}\n\n@ExtendWith(MockitoExtension.class)   // JUnit 5\n// @RunWith(MockitoJUnitRunner.class) // JUnit 4\npublic class MyTestAnnotated {\n  @Mock\n  private Random random;\n\n  @Test\n  public void test() {\n    final MyClass obj = new MyClass(random);\n    Assertions.assertEquals("0", obj.doWork()); // JUnit 5\n    // Assert.assertEquals("0", obj.doWork());  // JUnit 4\n  }\n}\n
Run Code Online (Sandbox Code Playgroud)\n

通过工厂延迟施工

\n

根据依赖项的构造函数参数的数量和表达代码的需要,可以使用 JDK 中的现有接口(SupplierFunctionBiFunction)或引入自定义工厂接口(@FunctionInterface如果它只有一个方法,则用 进行注释)。

\n

以下代码将选择自定义界面,但可以与Supplier<Random>.

\n
@FunctionalInterface\npublic interface RandomFactory {\n  Random newRandom();\n}\n\npublic class MyClass {\n  private final RandomFactory randomFactory;\n\n  public MyClass(final RandomFactory randomFactory) {\n    this.randomFactory = randomFactory;\n  }\n\n  // optional: make it easy to create "production" instances (again: I wouldn\'t recommend this)\n  public MyClass() {\n    this(Random::new);\n  }\n\n  public String doWork() {\n    return Integer.toString(randomFactory.newRandom().nextInt());\n  }\n}\n\npublic class MyTest {\n  @Test\n  public void test() {\n    final RandomFactory randomFactory = () -> Mockito.mock(Random.class);\n    final MyClass obj = new MyClass(randomFactory);\n    Assertions.assertEquals("0", obj.doWork()); // JUnit 5\n    // Assert.assertEquals("0", obj.doWork());  // JUnit 4\n  }\n}\n\n@ExtendWith(MockitoExtension.class)   // JUnit 5\n// @RunWith(MockitoJUnitRunner.class) // JUnit 4\npublic class MyTestAnnotated {\n  @Mock\n  private RandomFactory randomFactory;\n\n  @Test\n  public void test() {\n    // this is really awkward; it is usually simpler to use a lambda and create the mock manually\n    Mockito.when(randomFactory.newRandom()).thenAnswer(a -> Mockito.mock(Random.class));\n    final MyClass obj = new MyClass(randomFactory);\n    Assertions.assertEquals("0", obj.doWork()); // JUnit 5\n    // Assert.assertEquals("0", obj.doWork());  // JUnit 4\n  }\n}\n
Run Code Online (Sandbox Code Playgroud)\n

推论:(错误)使用@InjectMocks

\n
\n

但我正在使用@InjectMocks调试器并验证我在测试类中是否有模拟。然而,我设置的模拟方法Mockito.mock从未Mockito.when被调用!(换句话说:“我得到一个 NPE”、“我的集合为空”、“返回默认值”等)

\n

\xe2\x80\x94 一个困惑的开发人员,大约。2022年

\n
\n

用代码来表达,上面的引用看起来像这样:

\n
@ExtendWith(MockitoExtension.class)   // JUnit 5\n// @RunWith(MockitoJUnitRunner.class) // JUnit 4\npublic class MyTestAnnotated {\n  @Mock\n  private Random random;\n\n  @InjectMocks\n  private MyClass obj;\n\n  @Test\n  public void test() {\n    random = Mockito.mock(Random.class);\n    Mockito.when(random.nextInt()).thenReturn(42);\n    Assertions.assertEquals("42", obj.doWork()); // JUnit 5\n    // Assert.assertEquals("42", obj.doWork());  // JUnit 4\n  }\n}\n
Run Code Online (Sandbox Code Playgroud)\n

上面代码的问题在于方法中的第一行:它创建一个新的test()模拟实例并将其分配给该字段,从而有效地覆盖现有值。而是将原始值注入到被测试的类中 ( )。创建的实例仅存在于测试中,而不存在于被测试的类中。@InjectMocksobjMockito.mock

\n

这里的操作顺序是:

\n
    \n
  1. 所有@Mock带注释的字段都会分配一个新的模拟对象。
  2. \n
  3. -annotated字段从步骤 1@InjectMocks获取对模拟对象的注入引用。
  4. \n
  5. 测试类中的引用将被新模拟对象(通过创建Mockito.mock)的不同引用覆盖。原始参考已丢失,并且在测试类中不再可用。
  6. \n
  7. 被测试的类 ( obj) 仍然保留对初始模拟实例的引用并使用它。该测试仅引用新的模拟实例。
  8. \n
\n

这基本上可以归结为Java 是“按引用传递”还是“按值传递”?

\n

您可以使用调试器验证这一点。设置断点,然后比较测试类和被测类中模拟字段的对象地址/ID。您会注意到这是两个不同的、不相关的对象实例。

\n

解决方案?不要覆盖引用,而是设置通过注释创建的模拟实例。只需摆脱重新分配即可Mockito.mock

\n
@ExtendWith(MockitoExtension.class)   // JUnit 5\n// @RunWith(MockitoJUnitRunner.class) // JUnit 4\npublic class MyTestAnnotated {\n  @Mock\n  private Random random;\n\n  @InjectMocks\n  private MyClass obj;\n\n  @Test\n  public void test() {\n    // this.random must not be re-assigned!\n    Mockito.when(random.nextInt()).thenReturn(42);\n    Assertions.assertEquals("42", obj.doWork()); // JUnit 5\n    // Assert.assertEquals("42", obj.doWork());  // JUnit 4\n  }\n}\n
Run Code Online (Sandbox Code Playgroud)\n

推论:对象生命周期和神奇框架注释

\n
\n

我听从了您的建议并使用依赖项注入手动将模拟传递到我的服务中。它仍然不起作用,我的测试因空指针异常而失败,有时甚至在单个测试方法实际运行之前。你撒谎了,兄弟!

\n

\xe2\x80\x94 另一个困惑的开发人员,2022 年末

\n
\n

该代码可能类似于:

\n
@ExtendWith(MockitoExtension.class)   // JUnit 5\n// @RunWith(MockitoJUnitRunner.class) // JUnit 4\npublic class MyTestAnnotated {\n  @Mock\n  private Random random;\n\n  private final MyClass obj = new MyClass(random);\n\n  @Test\n  public void test() {\n    Mockito.when(random.nextInt()).thenReturn(42);\n    Assertions.assertEquals("42", obj.doWork()); // JUnit 5\n    // Assert.assertEquals("42", obj.doWork());  // JUnit 4\n  }\n}\n
Run Code Online (Sandbox Code Playgroud)\n

这与第一个推论非常相似,可以归结为对象生命周期和引用与值。上面代码中发生的步骤如下:

\n
    \n
  1. MyTestAnnotated测试框架创建了一个新实例(例如new MyTestAnnotated())。
  2. \n
  3. 所有构造函数和字段初始值设定项都会被执行。这里没有构造函数,但有一个字段初始值设定项:private MyClass obj = new MyClass(random);。此时,该random字段仍具有其默认值null\xe2\x86\x92 the objfield is located new MyClass(null)
  4. \n
  5. 所有@Mock带注释的字段都会分配一个新的模拟对象。这不会更新中的值MyService obj,因为它null最初是通过的,而不是对模拟的引用。
  6. \n
\n

根据您的MyService实现,在创建测试类的实例时这可能已经失败(MyService可能在构造函数中对其依赖项执行参数验证);或者它可能仅在执行测试方法时失败(因为依赖项为空)。

\n

解决方案?熟悉对象生命周期、字段初始值设定项顺序以及模拟框架可以/将注入其模拟并更新引用(以及更新哪些引用)的时间点。尽量避免将“神奇”框架注释与手动设置混合在一起。手动创建所有内容(模拟、服务),或者将初始化移至用@Before(JUnit 4) 或@BeforeEach(JUnit 5) 注释的方法。

\n
@ExtendWith(MockitoExtension.class)   // JUnit 5\n// @RunWith(MockitoJUnitRunner.class) // JUnit 4\npublic class MyTestAnnotated {\n  @Mock\n  private Random random;\n\n  private MyClass obj;\n\n  @BeforeEach // JUnit 5\n  // @Before  // JUnit 4\n  public void setup() {\n    obj = new MyClass(random);\n  }\n\n  @Test\n  public void test() {\n    Mockito.when(random.nextInt()).thenReturn(42);\n    Assertions.assertEquals("42", obj.doWork()); // JUnit 5\n    // Assert.assertEquals("42", obj.doWork());  // JUnit 4\n  }\n}\n
Run Code Online (Sandbox Code Playgroud)\n

或者,手动设置所有内容,无需注释,这需要自定义运行程序/扩展:

\n
public class MyTest {\n  private Random random;\n  private MyClass obj;\n\n  @BeforeEach // JUnit 5\n  // @Before  // JUnit 4\n  public void setup() {\n    random = Mockito.mock(random);\n    obj = new MyClass(random);\n  }\n\n  @Test\n  public void test() {\n    Mockito.when(random.nextInt()).thenReturn(42);\n    Assertions.assertEquals("42", obj.doWork()); // JUnit 5\n    // Assert.assertEquals("42", obj.doWork());  // JUnit 4\n  }\n}\n
Run Code Online (Sandbox Code Playgroud)\n

参考

\n\n