Java单元测试实践-14.Mock、Spy后Stub Spring成员变量中的方法

本文详细介绍如何在Java单元测试中使用Mockito对Spring组件中的多级依赖进行Mock和Spy,包括成员变量及其成员变量的替换,处理SpringBean单例特性,避免Stub操作覆盖,以及操作顺序的影响。

Java单元测试实践-00.目录(9万多字文档+700多测试示例)
https://blog.youkuaiyun.com/a82514921/article/details/107969340

1. Mock/Spy后Stub Spring成员变量中的方法

在被测试代码中,Spring的@Component组件实现类中通常会依赖其他的成员变量,可能存在多层的依赖关系。为了使被测试代码能够完成足够完备的测试,覆盖足够的场景与代码分支,需要对被测试代码进行细粒度的控制,可能需要对成员变量的方法进行Stub。

例如被测试代码中调用了A类的对象a1的fa1()方法,在a1.fa1()方法中,调用了B类的对象b1的fb1()方法,为了使a1.fa1()方法中调用的b1.fb1()方法的行为被Stub,可将a1对象中注入的b1对象替换为B类的Mock或Spy对象,并对其fb1()方法进行Stub。通过以上操作后,在调用a1.fa1()方法时,会调用B类的Mock/Spy对象被Stub的方法,可以按照需求改变其行为。

示意图如下所示:

pic

1.1. 使用Mock对象对成员变量进行替换

1.1.1. 替换成员变量为Mock对象

使用Whitebox.setInternalState()方法可以通过反射对私有的成员变量进行替换,可将Spring的@Component组件实现类中的成员变量替换为Mock对象,可对Mock对象的方法进行Stub,在被测试类调用成员变量的方法时按照需要改变行为。

示例如下:

被测试类为TestServiceB1Impl,在其中引用了TestServiceA1Impl类的实例,TestServiceB1Impl类的test1方法调用了TestServiceA1Impl类的test1方法。

生成TestServiceA1接口的Mock对象,对其test1方法进行Stub,并将TestServiceB1Impl实例中的TestServiceA1Impl类的实例替换为Mock对象。

当调用TestServiceB1Impl类的test1方法时,会调用TestServiceA1Impl类的Mock对象被Stub的test1方法。可参考示例TestSpMockMember类。

@Autowired
private TestServiceB1 testServiceB1;

TestServiceA1 testServiceA1 = Mockito.mock(TestServiceA1.class);

Mockito.when(testServiceA1.test1(Mockito.anyString())).thenReturn(TestConstants.MOCKED);

Whitebox.setInternalState(testServiceB1, testServiceA1);

1.1.2. 替换成员变量的成员变量为Mock对象

有时需要将被测试类的成员变量的成员变量替换为Mock对象,可以使用反射获取被测试类的成员变量( 可使用Whitebox.getInternalState()方法 ),再将成员变量的成员变量替换为Mock对象,并对其方法进行Stub。

示例如下:

被测试类为TestServiceC1Impl,在其中引用了TestServiceB1Impl类的实例,TestServiceB1Impl类中引用了TestServiceA1Impl类的实例。

TestServiceC1Impl类的test1()方法调用了TestServiceB1Impl类的test1()方法,在其中调用了TestServiceA1Impl类的test1()方法。

生成TestServiceA1接口的Mock对象,对其test1()方法进行Stub。通过反射获取TestServiceC1Impl类实例中的TestServiceB1Impl类的实例,将其中的TestServiceA1Impl类的实例替换为Mock对象。

当调用TestServiceC1Impl类的test1()方法时,会调用TestServiceB1Impl类的test1()方法,再调用TestServiceA1Impl类的Mock对象被Stub的test1()方法。可参考示例TestSpMockMemberOfM1类。

@Autowired
private TestServiceC1 testServiceC1;

TestServiceA1 testServiceA1 = Mockito.mock(TestServiceA1.class);

Mockito.when(testServiceA1.test1(Mockito.anyString())).thenReturn(TestConstants.MOCKED);

TestServiceB1 testServiceB1 = Whitebox.getInternalState(testServiceC1, TestServiceB1.class);

Whitebox.setInternalState(testServiceB1, testServiceA1);

1.1.3. Spring Bean单例与变量替换

由于Spring的Bean默认为单例的,将某个@Component组件对象中的成员变量替换为Mock/Spy对象后,所有注入该对象的其他原始对象均会受影响。

示意图如下所示:

pic

示例如下:

测试代码中引用了TestServiceC1Impl与TestServiceC2Impl类的实例,以上两个类均引用了TestServiceB1Impl类的实例,TestServiceB1Impl类中引用了TestServiceA1Impl类的实例。

TestServiceC1Impl与TestServiceC2Impl类的test1()方法调用了TestServiceB1Impl类的test1()方法,在其中调用了TestServiceA1Impl类的test1()方法。

生成TestServiceA1接口的Mock对象,对其test1()方法进行Stub。通过反射获取TestServiceC1Impl类实例中的TestServiceB1Impl类的实例,将其中的TestServiceA1Impl类的实例替换为Mock对象。

当调用TestServiceC1Impl或TestServiceC2Impl类的test1()方法时,会调用TestServiceB1Impl类的test1()方法,再调用TestServiceA1Impl类的Mock对象被Stub的test1()方法,因此TestServiceC1Impl与TestServiceC2Impl类的test1()方法的行为均被改变了。可参考示例TestSpMockMemberOfM2类。

@Autowired
private TestServiceC1 testServiceC1;

@Autowired
private TestServiceC2 testServiceC2;

TestServiceA1 testServiceA1 = Mockito.mock(TestServiceA1.class);

Mockito.when(testServiceA1.test1(Mockito.anyString())).thenReturn(TestConstants.MOCKED);

TestServiceB1 testServiceB1InC1 = Whitebox.getInternalState(testServiceC1, TestServiceB1.class);

Whitebox.setInternalState(testServiceB1InC1, testServiceA1);

1.1.4. 将多个类引用的实例替换为独立的Mock对象

有时可能出现多个不同的类引用了同一个类的实例,可能需要将不同类中的被引用类的实例替换为独立的Mock对象,使不同的类在调用被引用类时能够按照需要执行不同的操作。

示例如下:

测试代码中引用了TestServiceC1Impl与TestServiceC2Impl类的实例,以上两个类均引用了TestServiceB1Impl类的实例。

TestServiceC1Impl与TestServiceC2Impl类test1()方法均调用了TestServiceB1Impl类的test1()方法。

可以将TestServiceC1Impl与TestServiceC2Impl类的实例中的TestServiceB1Impl类的实例分别替换为独立的Mock对象,使以上两个类在调用TestServiceB1Impl类的test1()方法时能够按照需要分别进行Stub。可参考示例TestSpMockMemberOfM3类。

@Autowired
private TestServiceC1 testServiceC1;

@Autowired
private TestServiceC2 testServiceC2;

TestServiceB1 testServiceB1InC1 = Mockito.mock(TestServiceB1.class);

Mockito.when(testServiceB1InC1.test1(Mockito.anyString())).thenReturn(TestConstants.FLAG1);

Whitebox.setInternalState(testServiceC1, testServiceB1InC1);

TestServiceB1 testServiceB1InC2 = Mockito.mock(TestServiceB1.class);

Mockito.when(testServiceB1InC2.test1(Mockito.anyString())).thenReturn(TestConstants.FLAG2);

Whitebox.setInternalState(testServiceC2, testServiceB1InC2);

1.1.5. 替换成员变量时防止覆盖Stub操作

在将成员变量替换为Mock对象之前,需要先判断该成员变量目前是否已经是Mock对象,若已经是Mock对象,需要直接对该Mock对象进行Stub;若每次都生成新的Mock对象进行替换,会将之前的Mock对象的Stub操作覆盖。可以参考示例代码TestReplaceUtil类的replaceMockMember()/replaceSpyMember(),将指定对象中的指定类型成员变量替换为Mock/Spy对象,且可以防止之前设置的覆盖Stub操作。

  • 示例如下

被测试类为TestServiceB1Impl,在其中引用了TestServiceA1Impl类的实例,test1()与test3()方法分别调用TestServiceA1Impl类的同名方法。

  • 反例如下

在对TestServiceB1Impl类实例中的TestServiceA1Impl类实例的test1()方法进行Stub时,生成了TestServiceA1接口的Mock对象,对其test1()方法进行Stub,再将TestServiceB1Impl类实例中的TestServiceA1Impl类实例替换为Mock对象。

在对TestServiceB1Impl类实例中的TestServiceA1Impl类实例的test3()方法进行Stub时,生成了TestServiceA1接口的Mock对象,对其test3()方法进行Stub,再将TestServiceB1Impl类实例中的TestServiceA1Impl类实例替换为Mock对象。

以上操作导致TestServiceB1Impl类实例中的TestServiceA1Impl类实例的Mock对象被替换为最后一次生成的Mock对象,仅包含对test3()方法的Stub,对test1()方法的Stub已被覆盖。可参考示例TestSpMockMemberOfM4Run、TestSpMockMemberOfM4Mock类。

@Autowired
private TestServiceB1 testServiceB1;

TestServiceA1 testServiceA1Mock = Mockito.mock(TestServiceA1.class);

Mockito.when(testServiceA1Mock.test1(Mockito.anyString())).thenReturn(TestConstants.MOCKED);

Whitebox.setInternalState(testServiceB1, testServiceA1Mock);

TestServiceA1 testServiceA1Mock2 = Mockito.mock(TestServiceA1.class);

Mockito.when(testServiceA1Mock2.test3(Mockito.anyString())).thenReturn(TestConstants.MOCKED);

Whitebox.setInternalState(testServiceB1, testServiceA1Mock2);
  • 正例如下

在对TestServiceB1Impl类实例中的TestServiceA1Impl类实例的test1()方法进行Stub时,判断TestServiceB1Impl类实例中的TestServiceA1Impl类实例是否为Mock对象,若非Mock对象,则生成Mock对象,对其test1()方法进行Stub后,再将TestServiceB1Impl类实例中的TestServiceA1Impl类实例替换为Mock对象;若为Mock对象,则直接对Mock对象进行Stub。

在对TestServiceB1Impl类实例中的TestServiceA1Impl类实例的test3()方法进行Stub时,判断TestServiceB1Impl类实例中的TestServiceA1Impl类实例是否为Mock对象,若非Mock对象,则生成Mock对象,对其test3()方法进行Stub后,再将TestServiceB1Impl类实例中的TestServiceA1Impl类实例替换为Mock对象;若为Mock对象,则直接对Mock对象进行Stub。

以上操作使TestServiceB1Impl类实例中的TestServiceA1Impl类实例的Mock对象仅生成一次,包含了每次的Stub操作,不会有Stub操作被覆盖。可参考示例TestSpMockMemberOfM5Run、TestSpMockMemberOfM5Mock方法。

public static void mock2(TestServiceB1 testServiceB1) {

    TestServiceA1 testServiceA1InB1 = Whitebox.getInternalState(testServiceB1, TestServiceA1.class);

    if (testServiceA1InB1.getClass() == TestServiceA1Impl.class) {

        TestServiceA1 testServiceA1Mock = Mockito.mock(TestServiceA1.class);

        doMock2(testServiceA1Mock);

        Whitebox.setInternalState(testServiceB1, testServiceA1Mock);
    } else {
        doMock2(testServiceA1InB1);
    }
}

private static void doMock2(TestServiceA1 testServiceA1) {

    Mockito.when(testServiceA1.test3(Mockito.anyString())).thenReturn(TestConstants.MOCKED);
}

1.1.6. 变量替换与Stub的顺序

将变量替换为Mock对象,与对Mock对象的方法进行Stub的操作,以上两个操作的先后顺序不影响执行结果,可以任意指定。可参考示例TestSpMockMemberOrder类。

1.2. 使用Spy对象对成员变量进行替换

使用Spy对象对成员变量进行替换,与使用Mock对象对成员变量进行替换类似。

  • 替换成员变量为Spy对象

可参考示例TestSpSpyMember类。

  • 替换成员变量的成员变量为Spy对象

可参考示例TestSpSpyMemberOfM1类。

  • Spring Bean单例与变量替换

可参考示例TestSpSpyMemberOfM2类。

  • 将多个类引用的实例替换为独立的Spy对象

可参考示例TestSpSpyMemberOfM3类。

  • 替换成员变量时防止覆盖Stub操作

反例可参考示例TestSpSpyMemberOfM4Run、TestSpSpyMemberOfM4Spy类,正例可参考TestSpSpyMemberOfM5Run、TestSpSpyMemberOfM5Spy类。

  • 变量替换与Stub的顺序

可参考示例TestSpSpyMemberOrder类。

修改后报java.lang.AssertionError: Expecting code to raise a throwable. at com.htsc.service.FundCostStatisticsV2ServiceTest.testGetFundCost_JdbcTemplateThrowsDataAccessException(FundCostStatisticsV2ServiceTest.java:858) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50) at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26) at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325) at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78) at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57) at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290) at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71) at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58) at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268) at org.junit.runners.ParentRunner.run(ParentRunner.java:363) at org.mockito.internal.runners.JUnit45AndHigherRunnerImpl.run(JUnit45AndHigherRunnerImpl.java:37) at org.mockito.runners.MockitoJUnitRunner.run(MockitoJUnitRunner.java:62) at org.junit.runner.JUnitCore.run(JUnitCore.java:137) at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:69) at com.intellij.rt.junit.IdeaTestRunner$Repeater$1.execute(IdeaTestRunner.java:38) at com.intellij.rt.execution.junit.TestsRepeater.repeat(TestsRepeater.java:11) at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:35) at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:231) at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:55)
最新发布
10-17
你修改了测试代码后,仍然报错: ``` java.lang.AssertionError: Expecting code to raise a throwable. ``` 说明:**你的测试期望方法抛出异常,但实际上没有抛!** 我们已经知道问题的根源在于 **mock 没有生效 → 异常未被触发**。现在我们要一步步排查并彻底解决。 --- ## 🚨 你当前的状态(基于之前的对话) - 被测方法: ```java public StockcostsVO getFundCost(String sumType, String begDt, String endDt){ List<StockcostsVO> voList = this.getFundInterest(sumType, begDt, endDt); // ← 真正访问数据库的方法 if (voList != null && voList.size() > 0) { return voList.get(voList.size()-1); } return null; } ``` - `getFundInterest(...)` 是调用 `jdbcTemplate.queryForList(...)` 的地方。 - 你想测试:当数据库异常时,`getFundCost` 应传播 `DataAccessException`。 但测试失败 → 表明异常根本没有抛出来。 --- ## ✅ 排查清单(逐项检查) ### 🔎 1. 是否正确 mock 了 `jdbcTemplate.queryForList` 的重载方法? ✅ 必须使用能匹配实际调用的那个签名! #### ❌ 错误写法(常见): ```java when(jdbcTemplate.queryForList(anyString())).thenThrow(...); ``` → 只匹配无参数版本:`queryForList("SELECT ...")` #### ✅ 正确写法(推荐): ```java doThrow(new DataAccessException("数据库异常")) .when(jdbcTemplate) .queryForList( anyString(), any(Object[].class) // 匹配 queryForList(sql, param1, param2, ...) ); ``` > ⚠️ 如果你还用了 `Class<T>` 参数,比如: > ```java > jdbcTemplate.queryForList(sql, StockcostsVO.class, args); > ``` > 那你还得 mock 更具体的签名: > ```java > doThrow(...) > .when(jdbcTemplate) > .queryForList(anyString(), eq(StockcostsVO.class), any(Object[].class)); > ``` --- ### 🔎 2. `fundCostStatisticsV2ServiceUnderTest` 是 Mock 还是真实对象? #### ❌ 如果它是普通 new 出来的对象: ```java private FundCostStatisticsV2Service fundCostStatisticsV2ServiceUnderTest = new FundCostStatisticsV2Service(); ``` → 即使你 mock 了 `jdbcTemplate`,但如果这个 service 内部不是通过依赖注入使用的 `jdbcTemplate`,那你的 mock 就不会生效! #### ✅ 正确做法:确保 `jdbcTemplate` 被注入到被测对象中 ```java @RunWith(MockitoJUnitRunner.class) public class FundCostStatisticsV2ServiceTest { @Mock private JdbcTemplate jdbcTemplate; @InjectMocks private FundCostStatisticsV2Service fundCostStatisticsV2ServiceUnderTest; @Test public void testGetFundCost_JdbcTemplateThrowsDataAccessException() { // 给定:mock 数据库调用 doThrow(new DataAccessException("数据库异常")) .when(jdbcTemplate) .queryForList(anyString(), any(Object[].class)); // 当 + 那么 assertThatThrownBy(() -> fundCostStatisticsV2ServiceUnderTest.getFundCost("zyjj", "20200101", "20210101") ).isInstanceOf(DataAccessException.class); } } ``` 📌 关键点: - `@Mock`: 创建 mock 对象 - `@InjectMocks`: 创建真实 service,并把 mock 注入进去 - `jdbcTemplate` 必须是该类的字段(成员变量),且由 Spring 或构造函数传入 --- ### 🔎 3. `getFundInterest(...)` 方法是否为 `private`?能否被跳过? 如果 `getFundInterest(...)` 是私有方法,而且你在 `spy` 上尝试 mock 它,要小心 Mockito 默认不支持直接 stub 私有方法(除非用 `mockito-inline` 和 `doReturn(...).when(spy, "methodName", ...)`)。 不过你现在不需要 mock 它本身,只要它内部调用了 `jdbcTemplate`,而 `jdbcTemplate` 已被 mock → 就应该触发异常。 所以重点还是看 **`jdbcTemplate` 是否真的被调用**。 --- ### 🔎 4. 添加 `verify` 来验证 mock 是否被触发 加这一行来调试: ```java // 在 assertThatThrownBy 后面加 verify(jdbcTemplate).queryForList(anyString(), any(Object[].class)); ``` 👉 如果这行报错:“Wanted but not invoked”,说明根本没调用数据库! 可能原因: - SQL 构造逻辑有 `if` 判断提前返回 - 参数校验失败导致未执行查询 - 缓存命中,跳过了数据库查询 --- ### ✅ 示例:完整正确的单元测试结构 ```java @RunWith(MockitoJUnitRunner.class) public class FundCostStatisticsV2ServiceTest { @Mock private JdbcTemplate jdbcTemplate; @InjectMocks private FundCostStatisticsV2Service fundCostStatisticsV2ServiceUnderTest; @Test public void testGetFundCost_JdbcTemplateThrowsDataAccessException() { // 给定:模拟数据库异常 doThrow(new DataAccessException("数据库异常")) .when(jdbcTemplate) .queryForList( anyString(), any(Object[].class) ); // 当:调用业务方法 // 那么:应抛出 DataAccessException assertThatThrownBy(() -> fundCostStatisticsV2ServiceUnderTest.getFundCost("zyjj", "20200101", "20210101") ) .isInstanceOf(DataAccessException.class) .hasMessageContaining("数据库异常"); // 验证:确实尝试查询数据库 verify(jdbcTemplate).queryForList( anyString(), argThat(args -> args != null && args.length == 3) ); } } ``` --- ## ✅ 如何确认 `jdbcTemplate.queryForList` 是否被执行? 在 `getFundInterest(...)` 方法里加日志或断点: ```java System.out.println("Executing query with jdbcTemplate..."); List<StockcostsVO> result = jdbcTemplate.queryForList(sql, params); ``` 运行测试,看看有没有输出。如果没有 → 说明控制流压根没走到那里! 常见拦截点: ```java if (StringUtils.isEmpty(sumType)) { return Collections.emptyList(); } ``` → 导致 `jdbcTemplate` 不会被调用 → 异常也不会抛 → 测试失败 --- ## ✅ 最终解决方案总结 | 问题 | 解决方式 | |------|----------| | mock 方法签名不匹配 | 改成 `queryForList(anyString(), any(Object[].class))` | | 使用 `when().thenThrow()` 导致真实方法被调用 | 改用 `doThrow().when()` | | `jdbcTemplate` 没有注入到被测对象 | 使用 `@Mock + @InjectMocks` | | 控制流未进入数据库查询分支 | 加日志/断点确认执行路径 | | `verify()` 失败 → 没有调用 | 检查前置条件、参数合法性判断 | --- ## ✅ 建议立即做的三件事 1. **在 `getFundInterest(...)` 中添加日志**: ```java System.out.println(">>>>>> About to call jdbcTemplate.queryForList with SQL: " + sql); ``` 2. **添加 `verify(...)` 断言**: ```java verify(jdbcTemplate).queryForList(anyString(), any(Object[].class)); ``` 3. **打印 mock 状态(可选)**: ```java System.out.println("jdbcTemplate is mock? " + Mockito.mockingDetails(jdbcTemplate).isMock()); ``` ---
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值