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

zlwx9yxi  于 2023-04-21  发布在  其他
关注(0)|答案(1)|浏览(215)
前言:

这个问题和答案旨在作为由于误用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());
  }
}

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

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 :(
  }
}

即使使用MockitoJUnitRunner(JUnit 4)运行测试或使用MockitoExtension(JUnit 5)扩展并使用@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 :((
  }
}

为什么不使用模拟类,即使在测试类之前调用Mockito方法,或者使用Mockito扩展/运行器执行测试?
此问题的其他变体包括但不限于:

  • 我的模拟返回null /我的存根返回null
  • 使用Mockito时出现NullPointerException
  • 我的模拟在测试中为空
  • My mocks do not return the expected value / My stubs do not return the expected value
  • Mockito thenReturn未得到认可/ Mockito thenAnswer未得到认可
  • @InjectMocks不工作
  • @Mock不工作
  • Mockito.mock不工作
  • My class is not using mocks /我的类没有使用存根
  • 我的测试仍然调用或执行模拟/存根类的真实的实现
9gm1akwq

9gm1akwq1#

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

问题概述(类vs示例)

Mock是 instances(这就是为什么它们也被称为“mock object”)。在类上调用Mockito.mock将返回该类的mock object。它必须被分配给一个变量,然后可以传递给相关方法或作为依赖项注入到其他类中。它不会 * 修改类本身!想想看:如果这是真的,那么类的所有示例都将以某种方式神奇地转换为mock。这将使得无法模拟使用多个示例的类或来自JDK的类,如ListMap(首先不应该被模拟,但这是另一回事)。
对于使用Mockito扩展/runner的@Mock注解也是如此:创建一个mock对象的新 instance,然后将其分配给用@Mock注解的字段(或参数)。这个mock对象仍然需要传递给正确的方法或作为依赖项注入。
另一种避免这种混淆的方法是:Java中的new将 * 始终 * 为对象分配内存,并将初始化真实的类的这个新示例。不可能覆盖new的行为。即使是像Mockito这样聪明的框架也做不到。

解决方案

»但是我怎么能模拟我的类呢?«你会问。改变你的类的设计,使其可测试!每次你决定使用new时,你都要将自己提交给一个完全相同类型的示例。根据你的具体用例和需求,有多种选择,包括但不限于:
1.如果你可以改变方法的签名/接口,传递(mock)示例作为方法参数。这要求示例在所有调用站点都可用,这可能并不总是可行的。
1.如果你不能改变方法的签名,inject the dependency在你的构造函数中,并将它存储在一个字段中,以便以后由方法使用。
1.有时,示例必须在方法被调用时创建,而不是在方法被调用之前创建。在这种情况下,您可以引入另一个间接层并使用称为abstract factory pattern的东西。工厂对象将创建并返回依赖项的示例。工厂可以存在多个实现:一个返回真实的的依赖关系,另一个返回测试双精度型,例如mock。
下面您将找到每个选项的示例实现(有和没有Mockito runner/extension):

更改方法签名

public class MyClass {
  public String doWork(final Random random) {
    return Integer.toString(random.nextInt());
  }
}

public class MyTest {
  @Test
  public void test() {
    final Random mockedRandom = Mockito.mock(Random.class);
    final MyClass obj = new MyClass();
    Assertions.assertEquals("0", obj.doWork(mockedRandom)); // JUnit 5
    // Assert.assertEquals("0", obj.doWork(mockedRandom));  // JUnit 4
  }
}

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

  @Test
  public void test() {
    final MyClass obj = new MyClass();
    Assertions.assertEquals("0", obj.doWork(random)); // JUnit 5
    // Assert.assertEquals("0", obj.doWork(random));  // JUnit 4
  }
}

构造函数依赖注入

public class MyClass {
  private final Random random;

  public MyClass(final Random random) {
    this.random = random;
  }

  // optional: make it easy to create "production" instances (although I wouldn't recommend this)
  public MyClass() {
    this(new Random());
  }

  public String doWork() {
    return Integer.toString(random.nextInt());
  }
}

public class MyTest {
  @Test
  public void test() {
    final Random mockedRandom = Mockito.mock(Random.class);
    final MyClass obj = new MyClass(mockedRandom);
    // or just obj = new MyClass(Mockito.mock(Random.class));
    Assertions.assertEquals("0", obj.doWork()); // JUnit 5
    // Assert.assertEquals("0", obj.doWork());  // JUnit 4
  }
}

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

  @Test
  public void test() {
    final MyClass obj = new MyClass(random);
    Assertions.assertEquals("0", obj.doWork()); // JUnit 5
    // Assert.assertEquals("0", obj.doWork());  // JUnit 4
  }
}

工厂延迟施工

根据依赖项的构造函数参数的数量和对表达性代码的需求,可以使用JDK中的现有接口(SupplierFunctionBiFunction)或引入自定义工厂接口(如果只有一个方法,则用@FunctionInterface注解)。
下面的代码将选择一个自定义接口,但在Supplier<Random>上也能正常工作。

@FunctionalInterface
public interface RandomFactory {
  Random newRandom();
}

public class MyClass {
  private final RandomFactory randomFactory;

  public MyClass(final RandomFactory randomFactory) {
    this.randomFactory = randomFactory;
  }

  // optional: make it easy to create "production" instances (again: I wouldn't recommend this)
  public MyClass() {
    this(Random::new);
  }

  public String doWork() {
    return Integer.toString(randomFactory.newRandom().nextInt());
  }
}

public class MyTest {
  @Test
  public void test() {
    final RandomFactory randomFactory = () -> Mockito.mock(Random.class);
    final MyClass obj = new MyClass(randomFactory);
    Assertions.assertEquals("0", obj.doWork()); // JUnit 5
    // Assert.assertEquals("0", obj.doWork());  // JUnit 4
  }
}

@ExtendWith(MockitoExtension.class)   // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
  @Mock
  private RandomFactory randomFactory;

  @Test
  public void test() {
    // this is really awkward; it is usually simpler to use a lambda and create the mock manually
    Mockito.when(randomFactory.newRandom()).thenAnswer(a -> Mockito.mock(Random.class));
    final MyClass obj = new MyClass(randomFactory);
    Assertions.assertEquals("0", obj.doWork()); // JUnit 5
    // Assert.assertEquals("0", obj.doWork());  // JUnit 4
  }
}

推论:(错误)使用@InjectMocks

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

  • 一个困惑的开发者,约2022年
    如果用代码表示,上面的引用看起来像这样:
@ExtendWith(MockitoExtension.class)   // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
  @Mock
  private Random random;

  @InjectMocks
  private MyClass obj;

  @Test
  public void test() {
    random = Mockito.mock(Random.class);
    Mockito.when(random.nextInt()).thenReturn(42);
    Assertions.assertEquals("42", obj.doWork()); // JUnit 5
    // Assert.assertEquals("42", obj.doWork());  // JUnit 4
  }
}

上面代码的问题是test()方法的第一行:它创建并分配一个 new mock示例给字段,有效地覆盖了现有的值。但是@InjectMocks将原始值注入到测试中的类(obj)中。用Mockito.mock创建的示例只存在于测试中,而不存在于测试中的类中。
这里的操作顺序是:
1.所有带@Mock注解的字段都被分配了一个新的模拟对象。

  1. @InjectMocks注解字段被注入 references 到步骤1中的模拟对象。
    1.测试类中的引用被新的mock对象(通过Mockito.mock创建)的不同引用覆盖。原始引用丢失,在测试类中不再可用。
    1.被测类(obj)仍然持有对初始模拟示例的引用并使用它。测试只有对新模拟示例的引用。
    这基本上归结为Is Java "pass-by-reference" or "pass-by-value"?
    你可以用调试器来验证这一点。设置一个断点,然后比较测试类和被测类中模拟字段的对象地址/id。你会注意到这是两个不同的、不相关的对象示例。
    解决方案?不要覆盖引用,而是设置通过注解创建的mock示例。只需使用Mockito.mock消除重新赋值即可:
@ExtendWith(MockitoExtension.class)   // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
  @Mock
  private Random random;

  @InjectMocks
  private MyClass obj;

  @Test
  public void test() {
    // this.random must not be re-assigned!
    Mockito.when(random.nextInt()).thenReturn(42);
    Assertions.assertEquals("42", obj.doWork()); // JUnit 5
    // Assert.assertEquals("42", obj.doWork());  // JUnit 4
  }
}

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

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

  • 另一个困惑的开发者,2022年底
    代码可能看起来像这样:
@ExtendWith(MockitoExtension.class)   // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
  @Mock
  private Random random;

  private final MyClass obj = new MyClass(random);

  @Test
  public void test() {
    Mockito.when(random.nextInt()).thenReturn(42);
    Assertions.assertEquals("42", obj.doWork()); // JUnit 5
    // Assert.assertEquals("42", obj.doWork());  // JUnit 4
  }
}

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

  1. MyTestAnnotated的新示例由测试框架创建(例如new MyTestAnnotated())。
    1.所有的构造函数和字段初始化器都会被执行。这里没有构造函数,只有一个字段初始化器:此时,random字段仍具有其默认值null-obj字段被分配new MyClass(null)
    1.所有带@Mock注解的字段都被分配了一个新的mock对象。这不会更新MyService obj中的值,因为它最初传递给null,而不是对mock的引用。
    根据您的MyService实现,在创建测试类的示例时,这可能已经失败了(MyService可能会在构造函数中执行其依赖项的参数验证);或者它可能只在执行测试方法时失败(因为依赖项为空)。
    解决方案?熟悉对象生命周期、字段初始化器顺序以及mock框架可以/将注入其mock和更新引用的时间点(以及更新哪些引用)。尽量避免将“神奇”框架注解与手动设置混合在一起。要么手动创建所有内容(mocks,service),或者将初始化移动到用@Before(JUnit 4)或@BeforeEach(JUnit 5)注解的方法。
@ExtendWith(MockitoExtension.class)   // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
  @Mock
  private Random random;

  private MyClass obj;

  @BeforeEach // JUnit 5
  // @Before  // JUnit 4
  public void setup() {
    obj = new MyClass(random);
  }

  @Test
  public void test() {
    Mockito.when(random.nextInt()).thenReturn(42);
    Assertions.assertEquals("42", obj.doWork()); // JUnit 5
    // Assert.assertEquals("42", obj.doWork());  // JUnit 4
  }
}

或者,手动设置所有内容,而不需要注解,这需要自定义runner/extension:

public class MyTest {
  private Random random;
  private MyClass obj;

  @BeforeEach // JUnit 5
  // @Before  // JUnit 4
  public void setup() {
    random = Mockito.mock(random);
    obj = new MyClass(random);
  }

  @Test
  public void test() {
    Mockito.when(random.nextInt()).thenReturn(42);
    Assertions.assertEquals("42", obj.doWork()); // JUnit 5
    // Assert.assertEquals("42", obj.doWork());  // JUnit 4
  }
}

参考资料

相关问题