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());
    }
}

关键点解析:

  1. 先验证真实对象的行为
  2. 使用 Mockito.mockConstruction() 模拟构造函数
  3. 通过 try-with-resources 限定作用域
  4. 作用域内构造的 Fruit 对象会被替换为模拟对象
  5. 作用域外恢复真实构造行为

这种设计很巧妙,确保模拟效果是临时的,避免影响其他测试的并发执行。

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 拦截 GrinderWaterTank 的构造,通过标准 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 项目


原始标题:Overview of Mockito MockedConstruction