本文旨在解决Mockito Spy在测试中遇到的常见问题:当生产代码自行创建对象实例时,Spy的桩值无法生效。核心原因是测试代码中的Spy实例未被生产代码使用。解决方案是采用依赖注入模式,将依赖对象作为参数传递,而非在方法内部创建,从而确保测试中可以传入Spy实例,实现桩值的有效应用,提高代码可测试性。
理解Mockito Spy及其使用挑战
mockito是一个流行的Java单元测试框架,它允许开发者创建模拟对象(mocks)和部分模拟对象(spies)来隔离测试单元。spy与mock的区别在于,spy是对真实对象的包装,默认情况下会调用真实方法,只有在明确桩化(stub)时才会返回桩值;而mock则完全是虚构的,所有方法默认不执行任何操作,必须显式桩化。
在使用spy进行方法桩化时,一个常见的困惑是,尽管测试代码中已明确设置了桩值,但实际运行的生产代码却依然获取到真实对象的默认值或实际执行结果,而非桩定的值。这通常表现为测试不通过,因为生产代码的行为与预期不符。
问题根源:对象实例的不一致性
让我们通过一个具体的例子来剖析这个问题。假设我们有一个GetOptionBidPrice类,其中包含一个getBidPrice()方法,我们的生产代码如下:
// 生产代码片段 public class SomeService { public double calculateValue() { GetOptionBidPrice getOptionBidPrice = new GetOptionBidPrice(...); // 问题所在:内部创建实例 double bidPrice = getOptionBidPrice.getBidPrice(); // ... 使用 bidPrice 进行后续计算 return bidPrice * 2; // 示例 } }
在测试中,我们可能尝试对GetOptionBidPrice进行spy并桩化其getBidPrice()方法:
// 测试代码片段 import static org.mockito.Mockito.*; import org.junit.jupiter.api.Test; public class SomeServiceTest { @Test void testCalculateValueWithStubbedBidPrice() { // 创建一个GetOptionBidPrice的spy对象 GetOptionBidPrice spyGetOptionBidPrice = spy(GetOptionBidPrice.class); // 桩化getBidPrice()方法,使其返回100.0 doReturn(100.0).when(spyGetOptionBidPrice).getBidPrice(); // 尝试测试 SomeService SomeService someService = new SomeService(); // 创建 SomeService 实例 double result = someService.calculateValue(); // 调用待测试方法 // 预期 result 为 200.0 (100.0 * 2) // 实际 result 可能是 0.0 (因为生产代码中 new 了一个新的 GetOptionBidPrice 实例) // Assertions.assertEquals(200.0, result); } }
在这个场景中,尽管我们在测试中创建了spyGetOptionBidPrice并桩化了它的getBidPrice()方法,但在SomeService的calculateValue()方法内部,却通过new GetOptionBidPrice(…)又创建了一个全新的、真实的GetOptionBidPrice实例。这意味着calculateValue()方法使用的是一个与测试中spy对象完全不同的实例。因此,spy对象上的桩化设置对生产代码没有任何影响,生产代码依然调用的是其内部新创建实例的真实方法,返回真实值(例如,如果getBidPrice()的默认实现返回0,那么就会得到0)。
解决方案:依赖注入(Dependency Injection)
要解决上述问题,核心思想是确保生产代码使用的是测试中创建的spy实例,而不是自己创建新的实例。实现这一目标的标准模式是依赖注入(Dependency Injection, DI)。
依赖注入是一种设计模式,它将对象所依赖的其他对象的创建和管理职责从对象本身移除,转移到外部。这意味着一个对象不再负责创建其依赖项,而是由外部(通常是框架或测试代码)提供这些依赖项。
通过依赖注入,我们可以将GetOptionBidPrice实例作为参数传递给SomeService的方法,或者通过构造函数注入到SomeService中。
1. 重构生产代码
修改SomeService,使其不再内部创建GetOptionBidPrice实例,而是通过方法参数接收:
// 重构后的生产代码片段 public class SomeService { // 方式一:方法注入 public double calculateValue(GetOptionBidPrice getOptionBidPrice) { double bidPrice = getOptionBidPrice.getBidPrice(); // ... 使用 bidPrice 进行后续计算 return bidPrice * 2; } // 方式二:构造函数注入 (更推荐,因为它明确了对象的依赖关系) private final GetOptionBidPrice getOptionBidPrice; public SomeService(GetOptionBidPrice getOptionBidPrice) { this.getOptionBidPrice = getOptionBidPrice; } public double calculateValueViaConstructor() { double bidPrice = getOptionBidPrice.getBidPrice(); return bidPrice * 2; } }
2. 重构测试代码
现在,我们可以在测试中创建spy实例,并将其注入到SomeService中:
// 重构后的测试代码片段 import static org.mockito.Mockito.*; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; public class SomeServiceTest { @Test void testCalculateValueWithStubbedBidPrice_MethodInjection() { // 创建一个GetOptionBidPrice的spy对象 GetOptionBidPrice spyGetOptionBidPrice = spy(GetOptionBidPrice.class); // 桩化getBidPrice()方法,使其返回100.0 doReturn(100.0).when(spyGetOptionBidPrice).getBidPrice(); // 创建 SomeService 实例 SomeService someService = new SomeService(null); // 如果 SomeService 只有方法注入,构造器可以传 null 或其他占位符 // 或者 SomeService 可以有无参构造器 // 调用待测试方法,并传入spy对象 double result = someService.calculateValue(spyGetOptionBidPrice); // 验证结果 assertEquals(200.0, result, "桩化的值应被正确使用"); // 验证 getBidPrice 方法是否被调用 verify(spyGetOptionBidPrice).getBidPrice(); } @Test void testCalculateValueWithStubbedBidPrice_ConstructorInjection() { // 创建一个GetOptionBidPrice的spy对象 GetOptionBidPrice spyGetOptionBidPrice = spy(GetOptionBidPrice.class); // 桩化getBidPrice()方法,使其返回100.0 doReturn(100.0).when(spyGetOptionBidPrice).getBidPrice(); // 创建 SomeService 实例,通过构造函数注入spy对象 SomeService someService = new SomeService(spyGetOptionBidPrice); // 调用待测试方法 double result = someService.calculateValueViaConstructor(); // 验证结果 assertEquals(200.0, result, "桩化的值应被正确使用"); // 验证 getBidPrice 方法是否被调用 verify(spyGetOptionBidPrice).getBidPrice(); } }
生产环境中的使用:
在生产环境中,SomeService的调用方将传入真实的GetOptionBidPrice实例:
// 生产环境调用示例 public class MainApplication { public static void main(String[] args) { GetOptionBidPrice realGetOptionBidPrice = new GetOptionBidPrice(...); // 真实实例 SomeService someService = new SomeService(realGetOptionBidPrice); // 注入真实实例 double finalValue = someService.calculateValueViaConstructor(); System.out.println("Final calculated value: " + finalValue); } }
依赖注入的优势与注意事项
- 提高可测试性: 依赖注入是实现高可测试性代码的关键。通过注入依赖,我们可以轻松地在测试中使用模拟或桩化对象,从而隔离被测试单元,使其不依赖于外部系统的真实行为。
- 降低耦合度: 对象不再硬编码其依赖项的创建过程,而是通过外部提供,这降低了模块间的耦合度,使得代码更易于维护和扩展。
- 遵循单一职责原则: 一个类专注于其核心业务逻辑,而不必关心其依赖项的创建和生命周期管理。
- 灵活性: 相同的业务逻辑可以在不同的环境中(例如,开发、测试、生产)使用不同的依赖实现。
注意事项:
- 选择合适的注入方式: 构造函数注入是推荐的注入方式,因为它强制依赖项在对象创建时就必须提供,从而保证了对象处于有效状态。方法注入适用于可选依赖或在特定操作中才需要的依赖。
- 避免过度注入: 如果一个类的构造函数或方法需要注入过多的依赖项,这可能是一个代码异味,表明该类承担了过多的职责,可能需要重构。
- 结合DI框架: 在大型项目中,手动管理依赖注入会变得复杂。spring、Guice等DI框架可以自动化依赖的创建、配置和注入过程,大大简化了开发。
总结
当Mockito spy的桩值未生效时,几乎总是因为生产代码在内部自行创建了依赖对象的新实例,而不是使用了测试中准备好的spy实例。解决此问题的根本方法是采用依赖注入模式,将依赖对象作为参数传递或通过构造函数注入,确保生产代码和测试代码操作的是同一个(无论是真实还是桩化的)对象实例。掌握依赖注入不仅能解决Mockito spy失效的问题,更是编写高质量、可测试、可维护代码的基石。