1. 概述
编写单元测试时,我们常会遇到需要模拟对象构造的场景。例如测试具有紧密耦合依赖的遗留代码时。
本教程将介绍 Mockito 的一项新特性:通过构造函数调用生成模拟对象。想深入学习 Mockito 测试技巧?可以参考我们的Mockito 系列教程。
2. 依赖配置
首先需要在项目中添加 mockito 依赖:
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
⚠️ 如果使用 Mockito 5 以下版本,还需显式添加 mock maker inline 依赖:
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>5.2.0</version>
<scope>test</scope>
</dependency>
3. 关于构造函数模拟的思考
通常来说,在面向对象设计中,构造对象时返回模拟实例可能暗示设计问题。这往往意味着:
- 类与具体实现耦合过紧
- 代码难以测试
理想情况下,类的依赖应该由外部注入。但实际开发中,我们经常需要临时替换构造后的对象行为,这种情况特别适用于:
✅ 测试难以触发的场景(复杂对象层级)
✅ 测试与外部库/框架的交互
✅ 遗留代码改造
后续我们将展示如何使用 Mockito 的 MockConstruction 解决这些问题,通过控制对象创建和指定行为来简化测试。
4. 模拟基础构造函数
先创建简单的 Fruit 类作为测试目标:
public class Fruit {
public String getName() {
return "Apple";
}
public String getColour() {
return "Red";
}
}
编写测试模拟 Fruit 的构造函数:
@Test
void givenMockedContructor_whenFruitCreated_thenMockIsReturned() {
assertEquals("Apple", new Fruit().getName());
assertEquals("Red", new Fruit().getColour());
try (MockedConstruction<Fruit> mock = mockConstruction(Fruit.class)) {
Fruit fruit = new Fruit();
when(fruit.getName()).thenReturn("Banana");
when(fruit.getColour()).thenReturn("Yellow");
assertEquals("Banana", fruit.getName());
assertEquals("Yellow", fruit.getColour());
List<Fruit> constructed = mock.constructed();
assertEquals(1, constructed.size());
}
}
关键点解析:
- 先验证真实对象的行为
- 使用 Mockito.mockConstruction() 模拟构造函数
- 通过 try-with-resources 限定作用域
- 作用域内构造的 Fruit 对象会被替换为模拟对象
- 作用域外恢复真实构造行为
这种设计很巧妙,确保模拟效果是临时的,避免影响其他测试的并发执行。
5. 模拟类内部构造的对象
更常见的场景是:测试目标类在内部创建了需要模拟的对象。以咖啡机应用为例:
public class CoffeeMachine {
private Grinder grinder;
private WaterTank tank;
public CoffeeMachine() {
this.grinder = new Grinder();
this.tank = new WaterTank();
}
public String makeCoffee() {
String type = this.tank.isEspresso() ? "Espresso" : "Americano";
return String.format("Finished making a delicious %s made with %s beans", type, this.grinder.getBeans());
}
}
依赖的 Grinder 类:
public class Grinder {
private String beans;
public Grinder() {
this.beans = "Guatemalan";
}
public String getBeans() {
return beans;
}
public void setBeans(String beans) {
this.beans = beans;
}
}
WaterTank 类:
public class WaterTank {
private int mils;
public WaterTank() {
this.mils = 25;
}
public boolean isEspresso() {
return getMils() < 50;
}
//Getters and Setters
}
先测试未模拟构造函数时的行为:
@Test
void givenNoMockedContructor_whenCoffeeMade_thenRealDependencyReturned() {
CoffeeMachine machine = new CoffeeMachine();
assertEquals("Finished making a delicious Espresso made with Guatemalan beans", machine.makeCoffee());
}
现在模拟内部构造的对象:
@Test
void givenMockedContructor_whenCoffeeMade_thenMockDependencyReturned() {
try (MockedConstruction<WaterTank> mockTank = mockConstruction(WaterTank.class);
MockedConstruction<Grinder> mockGrinder = mockConstruction(Grinder.class)) {
CoffeeMachine machine = new CoffeeMachine();
WaterTank tank = mockTank.constructed().get(0);
Grinder grinder = mockGrinder.constructed().get(0);
when(tank.isEspresso()).thenReturn(false);
when(grinder.getBeans()).thenReturn("Peruvian");
assertEquals("Finished making a delicious Americano made with Peruvian beans", machine.makeCoffee());
}
}
核心技巧:使用 mockConstruction 拦截 Grinder 和 WaterTank 的构造,通过标准 when 语法设置模拟行为。这样就能独立测试 makeCoffee 方法,不受真实依赖影响。
6. 处理带参数的构造函数
当构造函数需要参数时,可以通过 MockedConstruction 的上下文获取参数:
给 WaterTank 添加新构造函数:
public WaterTank(int mils) {
this.mils = mils;
}
修改 CoffeeMachine 构造函数:
public CoffeeMachine(int mils) {
this.grinder = new Grinder();
this.tank = new WaterTank(mils);
}
编写测试:
@Test
void givenMockedContructorWithArgument_whenCoffeeMade_thenMockDependencyReturned() {
try (MockedConstruction<WaterTank> mockTank = mockConstruction(WaterTank.class,
(mock, context) -> {
int mils = (int) context.arguments().get(0);
when(mock.getMils()).thenReturn(mils);
});
MockedConstruction<Grinder> mockGrinder = mockConstruction(Grinder.class)) {
CoffeeMachine machine = new CoffeeMachine(100);
Grinder grinder = mockGrinder.constructed().get(0);
when(grinder.getBeans()).thenReturn("Kenyan");
assertEquals("Finished making a delicious Americano made with Kenyan beans", machine.makeCoffee());
}
}
}
关键改进:使用 lambda 表达式处理带参构造函数。lambda 接收模拟实例和构造上下文,通过 context.arguments() 获取参数并设置行为。
7. 修改默认模拟行为
默认情况下,未存根的方法返回 null。我们可以让模拟对象调用真实方法:
@Test
void givenMockedContructorWithNewDefaultAnswer_whenFruitCreated_thenRealMethodInvoked() {
try (MockedConstruction<Fruit> mock = mockConstruction(Fruit.class, withSettings().defaultAnswer(Answers.CALLS_REAL_METHODS))) {
Fruit fruit = new Fruit();
assertEquals("Apple", fruit.getName());
assertEquals("Red", fruit.getColour());
}
}
通过传递 MockSettings 参数,让未存根的方法调用真实实现。这种"部分模拟"在测试中很实用。
8. 总结
本文展示了如何使用 Mockito 模拟构造函数调用。核心要点:
- 通过 mockConstruction 在限定作用域内拦截构造
- 支持无参/带参构造函数的模拟
- 可自定义模拟对象的默认行为
- 特别适合测试遗留代码和复杂对象层级
✅ 记住:虽然构造函数模拟很强大,但优先考虑依赖注入改善设计。完整示例代码见 GitHub 项目。