Mock 测试实战:汽车电池监控调度单元测试
Mock 的核心思想
在本测试中,被测对象 EvBatteryMonitorScheduler 依赖两个外部服务:
| 依赖 | 作用 | Mock 方式 |
|---|---|---|
IEvBatteryService | 数据库读取车辆电池列表并提供电池估算 | @Mock 注解注入,通过 when(...).thenReturn(...) 控制返回值 |
RestTemplate | 向外部 API 发送 HTTP 请求创建故障工单 | @Mock 注解注入,通过 verify(...) 验证调用行为 |
Mock 的两个核心用途在本测试中的体现:
- 隔离依赖(Stub):用
when(batteryService.getVehicles()).thenReturn(...)模拟数据库返回一组车辆电池数据,让测试完全脱离真实数据库和外部 API。 - 行为验证(Verify):用
verify(restTemplate).postForObject(...)断言被测对象在特定条件下确实调用了或因去重/正常值而未调用创建工单的 HTTP 接口。
并通过注释逐步说明每处 Mock 的用意。
package com.bxsy.modules.ev.schedule;
import com.alibaba.fastjson.JSONObject;
import com.bxsy.modules.ev.entity.EvBattery;
import com.bxsy.modules.ev.service.IEvBatteryService;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.springframework.http.HttpEntity;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.client.RestTemplate;
import java.util.Collections;
import java.util.concurrent.ConcurrentHashMap;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* 汽车动力电池监控调度单元测试(EvBatteryMonitorScheduler)
*
* <p>验证电动汽车电池在低续航 / 低健康度场景下是否正确触发故障工单创建 HTTP 调用,
* 同时通过 Mock 隔离数据库与外部 API 依赖。</p>
*
* <h3>Mock 分层策略</h3>
* <ul>
* <li>{@code @Mock IEvBatteryService} —— 替代数据库访问层</li>
* <li>{@code @Mock RestTemplate} —— 替代 HTTP 客户端</li>
* </ul>
*/
@RunWith(MockitoJUnitRunner.class)
public class EvBatteryMonitorSchedulerTest {
private EvBatteryMonitorScheduler scheduler;
/**
* Mock 数据库访问层 —— 所有对车辆电池数据的查询都通过 when-thenReturn 控制
*/
@Mock
private IEvBatteryService batteryService;
/**
* Mock HTTP 客户端 —— 我们不希望测试时真的去调用故障工单 API
*/
@Mock
private RestTemplate restTemplate;
private static final String BASE_URL = "http://ticket-api.internal:8080/boot";
private static final String API_KEY = "test-key";
@Before
public void setUp() {
scheduler = new EvBatteryMonitorScheduler();
// 注入 mock 依赖 —— 被测对象内部通过 setter/字段注入时就用反射塞入 mock
ReflectionTestUtils.setField(scheduler, "batteryService", batteryService);
ReflectionTestUtils.setField(scheduler, "restTemplate", restTemplate);
ReflectionTestUtils.setField(scheduler, "ticketBaseUrl", BASE_URL);
ReflectionTestUtils.setField(scheduler, "ticketApiKey", API_KEY);
// 每次测试前清除去重标记
@SuppressWarnings("unchecked")
ConcurrentHashMap<String, Long> map =
(ConcurrentHashMap<String, Long>) ReflectionTestUtils.getField(scheduler, "lastAlarmTime");
if (map != null) {
map.clear();
}
}
// ==================== 低续航场景 ====================
@Test
public void testLowSocTriggersFaultTicket() {
// 构造一辆剩余续航仅 45 km 的汽车(阈值 80 km 以下触发)
EvBattery battery = buildBattery("VIN-001", "Tesla Model 3", "project-A");
JSONObject batteryResult = buildBatteryResult(45.0, 45.0, 90.0, 1.5);
// Mock 行为:查询车辆列表返回一辆车
when(batteryService.getVehicles()).thenReturn(Collections.singletonList(battery));
// Mock 行为:对这辆车的续航估算返回上面的结果
when(batteryService.estimateRemainingSoc(eq("VIN-001"), isNull(), isNull()))
.thenReturn(batteryResult);
scheduler.checkBatteryHealth();
// 验证 HTTP 调用 —— ArgumentCaptor 捕获实际发送的 body 做细粒度断言
ArgumentCaptor<HttpEntity<String>> captor = ArgumentCaptor.forClass(HttpEntity.class);
verify(restTemplate).postForObject(
eq(BASE_URL + "/ticket/auto/create"),
captor.capture(),
eq(String.class));
HttpEntity<String> entity = captor.getValue();
JSONObject body = JSONObject.parseObject(entity.getBody());
assertEquals("VIN-001", body.getString("vehicleVin"));
assertEquals("Tesla Model 3", body.getString("model"));
assertEquals("FLT", body.getString("workOrderType"));
assertEquals("EV_BATTERY", body.getString("bizType"));
assertEquals("SCHEDULE", body.getString("source"));
assertEquals("HIGH", body.getString("priority"));
assertEquals("III", body.getString("severityLevel"));
assertTrue(body.getString("title").contains("动力电池续航不足"));
// extJson 验证
JSONObject ext = JSONObject.parseObject(body.getString("extJson"));
assertEquals("EV_BATTERY", ext.getString("faultSource"));
assertEquals("Tesla Model 3", ext.getString("vehicleModel"));
assertTrue(ext.getString("faultSymptom").contains("45"));
assertEquals("BATTERY_DEGRADATION", ext.getString("rootCause"));
assertEquals("45.0", ext.getString("remainingRangeKm"));
assertEquals("90.0%", ext.getString("soh"));
// 验证 header
assertEquals("application/json",
entity.getHeaders().getFirst("Content-Type"));
assertEquals(API_KEY,
entity.getHeaders().getFirst("X-Internal-Api-Key"));
}
// ==================== 低健康度场景 ====================
@Test
public void testLowSohTriggersFaultTicket() {
EvBattery battery = buildBattery("VIN-002", "BYD Han", null);
// 续航正常(350 km),但 SOH 低(58% < 70% 阈值) → 触发低健康度工单
JSONObject batteryResult = buildBatteryResult(350.0, 85.0, 58.0, 6.0);
when(batteryService.getVehicles()).thenReturn(Collections.singletonList(battery));
when(batteryService.estimateRemainingSoc(eq("VIN-002"), isNull(), isNull()))
.thenReturn(batteryResult);
scheduler.checkBatteryHealth();
ArgumentCaptor<HttpEntity<String>> captor = ArgumentCaptor.forClass(HttpEntity.class);
verify(restTemplate).postForObject(
eq(BASE_URL + "/ticket/auto/create"),
captor.capture(),
eq(String.class));
JSONObject body = JSONObject.parseObject(captor.getValue().getBody());
assertEquals("VIN-002", body.getString("vehicleVin"));
assertEquals("II", body.getString("severityLevel")); // SOH 低为 II 级
assertTrue(body.getString("title").contains("电池健康度过低"));
JSONObject ext = JSONObject.parseObject(body.getString("extJson"));
assertEquals("58.0%", ext.getString("soh"));
assertTrue(ext.getString("faultSymptom").contains("58"));
}
// ==================== 正常场景不触发 ====================
@Test
public void testNormalValuesDoNotTrigger() {
EvBattery battery = buildBattery("VIN-003", "NIO ET7", "project-B");
JSONObject batteryResult = buildBatteryResult(500.0, 95.0, 95.0, 10.0); // 一切正常
when(batteryService.getVehicles()).thenReturn(Collections.singletonList(battery));
when(batteryService.estimateRemainingSoc(eq("VIN-003"), isNull(), isNull()))
.thenReturn(batteryResult);
scheduler.checkBatteryHealth();
// 不应该调用故障工单 API —— verify(never()) 验证未调用
verify(restTemplate, never()).postForObject(anyString(), any(), any());
}
// ==================== 同时低续航+低健康度 ====================
@Test
public void testBothLowTriggersTwoTickets() {
EvBattery battery = buildBattery("VIN-004", "XPeng P7", "project-C");
JSONObject batteryResult = buildBatteryResult(30.0, 25.0, 50.0, 0.5); // 两者都低于阈值
when(batteryService.getVehicles()).thenReturn(Collections.singletonList(battery));
when(batteryService.estimateRemainingSoc(eq("VIN-004"), isNull(), isNull()))
.thenReturn(batteryResult);
scheduler.checkBatteryHealth();
// 应调用两次(低续航 + 低健康度各一次)—— times(2) 验证调用次数
verify(restTemplate, times(2)).postForObject(
eq(BASE_URL + "/ticket/auto/create"),
any(HttpEntity.class),
eq(String.class));
}
// ==================== 未配置 API 时跳过 ====================
@Test
public void testSkipWhenApiNotConfigured() {
// 清除 baseUrl / apiKey
ReflectionTestUtils.setField(scheduler, "ticketBaseUrl", "");
ReflectionTestUtils.setField(scheduler, "ticketApiKey", "");
EvBattery battery = buildBattery("VIN-005", "理想 L9", null);
JSONObject batteryResult = buildBatteryResult(20.0, 50.0, 40.0, 0.3);
when(batteryService.getVehicles()).thenReturn(Collections.singletonList(battery));
when(batteryService.estimateRemainingSoc(anyString(), isNull(), isNull()))
.thenReturn(batteryResult);
scheduler.checkBatteryHealth();
verify(restTemplate, never()).postForObject(anyString(), any(), any());
}
// ==================== 车辆列表为空 ====================
@Test
public void testEmptyVehicleList() {
// Mock 返回空列表
when(batteryService.getVehicles()).thenReturn(Collections.emptyList());
scheduler.checkBatteryHealth();
// 因为没有任何车辆,所以后续的续航估算和 HTTP 调用都不应发生
verify(batteryService, never()).estimateRemainingSoc(anyString(), any(), any());
verify(restTemplate, never()).postForObject(anyString(), any(), any());
}
// ==================== 去重:6小时内不重复建单 ====================
@Test
public void testDedupWithinWindow() {
EvBattery battery = buildBattery("VIN-006", "AION LX", null);
JSONObject batteryResult = buildBatteryResult(15.0, 30.0, 80.0, 0.5);
when(batteryService.getVehicles()).thenReturn(Collections.singletonList(battery));
when(batteryService.estimateRemainingSoc(eq("VIN-006"), isNull(), isNull()))
.thenReturn(batteryResult);
// 第一次调用 → 触发
scheduler.checkBatteryHealth();
verify(restTemplate, times(1)).postForObject(anyString(), any(), any());
// 第二次调用 → 去重,不触发
scheduler.checkBatteryHealth();
verify(restTemplate, times(1)).postForObject(anyString(), any(), any()); // 还是1次
}
// ==================== 辅助方法 ====================
private EvBattery buildBattery(String vin, String model, String projectId) {
EvBattery b = new EvBattery();
b.setVin(vin);
b.setModel(model);
if (projectId != null) {
b.setProjectId(projectId);
}
return b;
}
private JSONObject buildBatteryResult(double remainingRangeKm, double soc, double soh,
double estimatedRemainingHours) {
JSONObject r = new JSONObject();
r.put("remainingRangeKm", remainingRangeKm);
r.put("soc", soc);
r.put("soh", soh);
r.put("estimatedRemainingHours", estimatedRemainingHours);
return r;
}
}
Mock 使用要点总结
在上面的测试中,每条用例都遵循 Arrange-Act-Assert 模式,Mock 集中在 Arrange 和 Assert 两个阶段:
| 阶段 | Mock 操作 | 示例 |
|---|---|---|
| Arrange(准备) | when(...).thenReturn(...) 预设 Mock 对象的行为 | when(batteryService.getVehicles()).thenReturn(...) |
| Act(执行) | 调用被测对象的方法 | scheduler.checkBatteryHealth() |
| Assert(断言) | verify(...) 验证 Mock 对象是否按预期被调用 | verify(restTemplate, times(2)).postForObject(...) |
关键 Mock 技巧:
@Mock+@RunWith(MockitoJUnitRunner.class):自动初始化 Mock 对象,无需手动Mockito.mock()。ArgumentCaptor:捕获实际传入 HTTP 请求的 body,从而对 JSON 字段做深度断言。verify(..., never()):断言某个 Mock 方法在特定条件下不应该被调用,验证防御逻辑。verify(..., times(n)):精确验证调用次数,特别适合验证"同时低续航 + 低健康度生成两张工单"这类场景。

2050

被折叠的 条评论
为什么被折叠?



