阶段:框架篇 | 建议时长:4 天 | 前置:04-框架篇/01-Spring核心.md、04-框架篇/02-SpringBoot实战.md
学习目标
- 能说清测试金字塔,以及单元测试/集成测试/E2E 的边界与各自该测什么。
- 会用 JUnit 5 的注解与参数化测试,会为 Service 写带 Mock 的独立单测。
- 能区分
@SpringBootTest与@WebMvcTest(切片),并解释@MockBean的作用。 - 知道怎么保证测试不污染数据库(
@Transactional回滚 / 独立测试库 / Testcontainers)。 - 能回答”你们怎么保证代码质量”——测试 + 规范 + 静态检查 + CI 四件套。
核心知识点
一、测试金字塔与三种测试
面试官想听:不是”我们会写单测”,而是能说清为什么单测要占大头。
/ E2E \ 少量(关键业务闭环),慢、脆、贵
/ 集成测试 \ 中等(多个组件 + 真实中间件协作)
/ 单元测试 \ 大量(单个类/方法,无外部依赖,毫秒级)
| 层次 | 测什么 | 依赖 | 数量 | 典型工具 |
|---|---|---|---|---|
| 单元测试 UT | 一个类的逻辑分支,DAO/RPC 全 mock | 不起 Spring | 几百上千 | JUnit 5 + Mockito,毫秒级 |
| 集成测试 IT | 多组件协作、真实 SQL、事务生效 | 起容器/真库 | 几十 | @SpringBootTest + Testcontainers |
| 端到端 E2E | 从界面点到落库的业务闭环 | 完整环境 | 个位数 | Selenium / 手工冒烟 |
- 为什么面试爱问「你们怎么保证质量」:这题考的是工程习惯与协作成熟度,不是记 API。答得好的人会说流程(Review + CI + 测试 + 规范),而不是”我很仔细”。
- 一句话:单测的价值不在”发现 bug”,在”敢改代码”——没有测试的重构等于裸奔。
二、JUnit 5
class TodoServiceTest {
@BeforeEach void setUp() { /* 每个 @Test 前跑,造公共数据 */ }
@AfterEach void tearDown() { /* 每个之后跑,清理 */ }
@BeforeAll static void initAll() { /* 全部之前,必须 static */ }
@Test
@DisplayName("正常创建待办:应返回自增 id 且状态为未完成") // 报告里显示中文,评审友好
void create_ok() {
int a = 1 + 2;
assertAll( // ⭐一组断言全部执行完再汇总失败,比第一个就抛异常信息量大
() -> assertEquals(3, a),
() -> assertTrue(a > 0),
() -> assertNull(null)
);
}
@Test
void 空标题应抛异常() {
// 断言异常类型 + 消息,异常场景是单测最容易漏的一块
BusinessException ex = assertThrows(BusinessException.class,
() -> todoService.create(new TodoDTO()));
assertEquals("标题不能为空", ex.getMessage());
}
}
常用断言:assertEquals / assertNotEquals / assertTrue / assertNull / assertSame(比引用)/ assertIterableEquals / assertLinesMatch / assertTimeoutPreemptively(限时)。
参数化测试(把 N 个用例写成 1 个方法)
@ParameterizedTest
@CsvSource({"' ', false", "'', false", "'写周报', true"}) // 多行用例
void 标题校验(String title, boolean expected) {
assertEquals(expected, todoService.isValidTitle(title));
}
@ParameterizedTest @ValueSource(ints = {1, 5, 100, 999}) // 单参数
void 分页大小合法(int size) { assertTrue(pageService.isLegal(size)); }
@ParameterizedTest @MethodSource("badIds") // 复杂对象来源
void 非法id应拒绝(long id) { assertThrows(BusinessException.class, () -> service.get(id)); }
static Stream<Arguments> badIds() { return Stream.of(Arguments.of(-1L), Arguments.of(0L)); }
- 生命周期与命名注意:JUnit 5 每个 @Test 都新建一个测试类实例(与 JUnit 4 一致,保证用例互不影响),所以
@BeforeAll才必须 static。 - 别在测试里让用例互相依赖顺序(
@TestMethodOrder是坏味道信号)。
三、Mockito
一句话:Mockito 让你在不启动 Spring、不连数据库的前提下测 Service 的逻辑。
@ExtendWith(MockitoExtension.class) // ⭐JUnit 5 用这个;写成 @RunWith 是老 JUnit 4 的
class TodoServiceTest {
@Mock private TodoMapper todoMapper; // 假对象:所有方法默认返回 null/0/false
@Mock private RedisTemplate<String, Object> redis;
@InjectMocks private TodoService todoService; // 把上面所有 @Mock 注入进来
@Test
void 创建时应写库并清缓存() {
TodoDTO dto = new TodoDTO(); dto.setTitle("写周报");
when(todoMapper.insert(any(Todo.class))).thenAnswer(inv -> { // 模拟 MyBatis-Plus 回填主键
inv.getArgument(0, Todo.class).setId(1L);
return 1;
});
Long id = todoService.create(dto);
assertEquals(1L, id);
verify(todoMapper).insert(any()); // 确实调了 1 次(默认 times(1))
verify(redis, times(1)).delete("todo:list"); // 次数断言
verify(redis, never()).delete("todo:detail:*"); // ⭐never:不该发生的也要断言
}
@Test
void 查不存在的待办应抛业务异常() {
when(todoMapper.selectById(99L)).thenReturn(null);
assertThrows(BusinessException.class, () -> todoService.get(99L));
}
@Test
void 保存时应带上当前用户ID() {
ArgumentCaptor<Todo> captor = ArgumentCaptor.forClass(Todo.class); // ⭐参数捕获:看传进去的到底是什么
todoService.create(buildDto());
verify(todoMapper).insert(captor.capture());
assertEquals("u1001", captor.getValue().getCreateBy());
}
}
when(...).thenReturn(...)打桩(stub);thenThrow模拟依赖抛异常(这条最重要:数据库超时时你的 Service 怎么处理?);thenAnswer需要动态/副作用时用。verify(mock, times(2))/atLeastOnce()/never();verifyNoMoreInteractions()严格模式(少用)。- ⭐spy 慎用:
@Spy包真实对象,默认走真实逻辑,容易写出”其实是在测真实实现”的假单测;出现 spy 通常说明这个类该拆了。 - 常见坑:
when(mock.list()).thenReturn(...)里对未打桩的方法返回 null,链式调用直接 NPE;MockitoExtension默认 strict stubs,多余的打桩会报错(这是好事,能逼你删无用桩)。 - 别 mock 的东西:值对象/DTO、JDK 集合、静态常量、你自己的被测类本身。
四、Spring 测试:整体启动 vs 切片
| 注解 | 启动什么 | 速度 | 适用 |
|---|---|---|---|
@SpringBootTest | 整个应用上下文(所有 Bean、自动配置全跑) | 慢(秒级起) | 集成测试:真连 MyBatis、事务、Redis(或 Testcontainers) |
@WebMvcTest | 只扫 Web 层:@Controller/@ControllerAdvice/Filter/序列化,不装 Service 和 Mapper | 快 | 测 URL 映射、参数校验、JSON 结构、异常处理 |
@DataJpaTest / @MybatisTest | 只起持久层 + 内嵌库 | 中 | 测 SQL、动态 SQL、分页插件 |
@JsonTest | 只测 JSON 序列化 | 快 | 测字段命名、日期格式、@JsonIgnore |
@WebMvcTest(TodoController.class)
class TodoControllerTest {
@Autowired MockMvc mockMvc; // MockMvc:不真起端口,直接喂一个模拟请求
@MockBean TodoService todoService; // ⭐把容器里的 Service 换成 mock(不是 @Mock!)
@Test
void GET_按id查待办_应返回200和json字段() throws Exception {
when(todoService.get(1L)).thenReturn(new TodoVO(1L, "写周报", false));
mockMvc.perform(get("/api/todos/{id}", 1L)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.title").value("写周报"))
.andExpect(jsonPath("$.done").value(false));
}
}
MockMvc(同步、Spring 官方)vsTestRestTemplate/RestTestTemplate(真起随机端口@SpringBootTest(webEnvironment = RANDOM_PORT),能测到过滤器与拦截器的完整链路)。- 整链路集成测试的注解组合:
@SpringBootTest+@AutoConfigureMockMvc——前者启动完整容器(Controller→Service→Mapper 真实装配),后者自动配置 MockMvc 让你不必真起端口也能发请求。只测 Web 层用@WebMvcTest(更快的切片测试,Service 要 mock)。 - ⭐
@MockBean会让上下文缓存失效从而重启容器,大量使用会显著拖慢测试;纯单测就别起 Spring。 - 测试隔离:
@Transactional标在测试类/方法上,测试结束自动回滚(这是 Spring 测试最常用的一招);但注意它回滚不了 Redis、消息队列,也会让”事务传播行为”测不出来(内层事务被合并)。
数据库测试的三条路
@Transactional自动回滚 —— 最省事,但依赖同一连接、测不到真实提交效果。- 独立测试库 + 每轮
clean(或 Flyway/@Sql脚本重建数据)—— 团队 CI 常用。 - ⭐Testcontainers(了解即可):测试时用 Docker 真起一个 MySQL/Redis 容器,跑完销毁。兼顾”真环境”和”可重复”,代价是慢、CI 需要 Docker。
- H2 内存库:快但 SQL 方言和 MySQL 不完全兼容(
ON DUPLICATE KEY、limit写法会骗你),MyBatis 项目慎用。
五、覆盖率 JaCoCo
<plugin>
<groupId>org.jacoco</groupId><artifactId>jacoco-maven-plugin</artifactId><version>0.8.12</version>
<executions>
<execution><goals><goal>prepare-agent</goal></goals></execution>
<execution><id>report</id><phase>test</phase><goals><goal>report</goal></goals></execution>
</executions>
</plugin>
mvn test后看target/site/jacoco/index.html:行覆盖、分支覆盖、指令覆盖。- ⭐答题口径:覆盖率不是目标,关键路径的分支覆盖才是。行覆盖 80% 但
if的 else 分支从没进过,等于没测;追求 100% 会催生一堆assertNotNull(x)式的水测试。 - 团队实践:只对本次改动的文件要求增量覆盖率(diff coverage,Sonar 的 “new code coverage”),比全局门槛合理得多。
六、工程质量顺带这一节
Lombok:常用注解与原理
| 注解 | 生成什么 | 注意点 |
|---|---|---|
@Data | getter/setter/toString/equals/hashCode/@RequiredArgsConstructor | ⭐实体上慎用:equals/hashCode 会带上所有字段(含大集合),可能栈溢出;JPA 实体建议 @Getter @Setter |
@Builder | 建造者,Todo.builder().id(1L).build() | 与 Jackson 反序列化配合要加 @AllArgsConstructor |
@Slf4j | private static final Logger log = LoggerFactory.getLogger(X.class) | 少写一行样板 |
@RequiredArgsConstructor | final 字段的构造器 | ⭐配合构造器注入的最佳写法,替代 @Autowired 字段注入 |
@Getter/@Setter、@NoArgsConstructor、@ToString | 局部生成 | — |
-
原理(面试点):编译期注解处理器,通过改 AST 语法树插入方法字节码,运行时反射读不到”Lombok 生成的东西”和手写的一模一样。所以它不是运行时动态生成,
@Retention(SOURCE);代价是 IDE 必须装插件、Javadoc 里看不到方法。 -
追问:为什么
@Builder加在无@AllArgsConstructor的类上会报错?因为 builder 的build()需要一个能填全字段的构造器。 -
MapStruct 一句话:编译期生成对象转换代码(DTO ↔ PO),比手写
BeanUtils.copyProperties快(无反射)、比反射安全(字段改名编译就报错)。
日志规范:SLF4J 门面 + Logback 实现
log.info("订单创建成功 userId={} amount={} traceId={}", userId, amount, traceId);
- ⭐必须用占位符
{},不要log.info("user=" + userId):字符串拼接在日志级别被关闭时照样执行,白白浪费;占位符是延迟求值。 - 别用
System.out.println:无级别、无时间戳、无线程名、无法落文件与收集、不能被 ELK 采(见《05-Elasticsearch》)。 - 异常打印:
log.error("下单失败 orderId=" + id, e)—— ⭐异常对象必须作为最后一个参数(不要e.getMessage(),那样堆栈全丢)。 - 别到处
e.printStackTrace()(写 stderr,可能不进日志文件)。
| 级别 | 用在哪 | 生产是否开 |
|---|---|---|
ERROR | 需要人介入的故障:报警、异常堆栈 | ✅ |
WARN | 异常但可自愈:降级、重试成功、参数不合法 | ✅ |
INFO | 关键业务节点:下单、支付回调、启动参数 | ✅(别刷屏) |
DEBUG | 排查细节:入参出参、SQL | 临时开 |
TRACE | 最细,循环内部 | 基本不开 |
- 追问:线上想临时改日志级别怎么办?——Spring Boot Actuator
/actuator/loggers/{name}可动态调整,不用重启。
阿里巴巴 Java 开发手册(要点摘要,非照抄)
| 规约 | 要求 | 为什么 |
|---|---|---|
| 命名 | 类名 UpperCamelCase、方法/变量 lowerCamelCase、常量全大写下划线;抽象类 Abstract/Base 开头、异常类 Exception 结尾、测试类”被测类名 + Test” | 一眼看出用途 |
| POJO 布尔 | ⭐布尔字段不要加 is 前缀(用 deleted 不用 isDeleted) | 框架按 getter 推导属性名,isDeleted() → deleted,导致字段丢失/反序列化为 null |
| 包装类型 | POJO 属性、RPC 返回值与参数用包装类型;局部变量用基本类型 | 基本类型默认 0,分不清”没传”和”传了 0”,数据库列可能为 null |
| Integer 比较 | ⭐整型包装类值比较一律用 equals() | 缓存只到 -128~127,超出后 == 为 false(面试与线上双坑) |
| 金额 | BigDecimal 且构造传 String(new BigDecimal("0.1"));除法必须指定 scale 与 RoundingMode | double 无法精确表示 0.1 |
| 并发 | ⭐线程池禁止用 Executors 快捷方法创建,必须 new ThreadPoolExecutor(...) 手动指定七参数 | 队列/线程数无界 → OOM;手册强制项也是面试高频 |
| 并发 | SimpleDateFormat 线程不安全(用 DateTimeFormatter);ThreadLocal 用完必须 remove() | 线程池复用线程会造成脏数据与内存泄漏 |
| MySQL | ⭐禁止 SELECT *;WHERE 列上避免函数与隐式类型转换(varchar 列传 int → 索引失效) | 覆盖索引失效、回表代价 |
| 工程 | 不允许魔法值;异常不做流程控制、不在 finally 里 return;对外接口必须幂等 | 可读性 + “吞异常”事故 |
- 顺带提一句静态检查:SpotBugs / PMD / Checkstyle / SonarQube 把这些规约变成 CI 里的硬门槛,比口头强调有效。
- 一句话:规范的价值是”团队里任何一个人写的代码,另一个人都能一眼读懂”。
动手实践
任务 1:给 TodoService 写 3 个单测(不起 Spring)
- 要求:
@ExtendWith(MockitoExtension.class)+@Mock TodoMapper+@InjectMocks TodoService,覆盖三条路径:① 创建成功(verify 写库 + 清缓存);② 标题为空抛BusinessException(assertThrows);③ 列表查询(用@ParameterizedTest覆盖 正常/空结果/超长分页 三种入参)。 - 验收标准:三个测试类方法都能在没有 MySQL、没有 Redis 的情况下跑通;至少一处用
verify(..., never())断言”不该发生的事”;至少一个用例用ArgumentCaptor校验落库对象的字段值;mvn test全绿且启动耗时 < 1 秒(证明没起 Spring 上下文)。
任务 2:给 Controller 加 MockMvc 切片测试
- 要求:
@WebMvcTest(TodoController.class)+@MockBean TodoService,测GET /api/todos/{id}(200 + jsonPath 校验字段)与POST /api/todos传空标题(400 + 错误信息),断言@Valid校验确实生效。 - 验收标准:两个用例分别断言状态码和
$.code/$.message字段;能说出@WebMvcTest下 Service 为什么必须@MockBean而不是@Mock(容器里没有这个 Bean,切片不扫描 Service);测试类里没有任何真实数据库连接。
任务 3:加 JaCoCo 并补一个分支
- 要求:接入 jacoco-maven-plugin,跑
mvn test,找出TodoService里未被覆盖的 else 分支并补一个测试。 - 验收标准:能截图或贴出报告里该类的分支覆盖率数字,补测前后各一个数值;能说出行覆盖与分支覆盖的差别,以及为什么不追求 100%。
高频面试题
- Q:@SpringBootTest 和 @WebMvcTest 有什么区别?⭐ —— 前者启动整个 ApplicationContext(所有 Bean + 自动配置,慢,测集成);后者只加载 Web 层切片(Controller/Advice/序列化),Service 要用 @MockBean 顶替,快 | 详见《SpringBoot实战》
- Q:mock 和 stub 的区别?⭐ —— 都是假对象;stub 关注”返回值”(when-thenReturn 让被测代码拿到预设数据),mock 关注”交互”(verify 断言有没有被调用、调用几次、参数是什么);Mockito 一个工具两种用法 | 详见《05-单元测试与工程质量》
- Q:private 方法怎么测?⭐ —— 口径是”不该直接测”:private 是实现细节,测它会让重构寸步难行;应该通过 public 入口覆盖它的分支,或者这个类已经承担太多职责、该拆出来(拆成独立类的 public 方法再测) | 详见《07-设计模式》
- Q:测试为什么会污染数据库?怎么解决?⭐ —— 测试插的数据留在库里导致后续用例互相影响;解法:测试类标 @Transactional 结束自动回滚、独立测试库每轮 clean、Testcontainers 每次起新容器;注意 @Transactional 测不出事务传播且回滚不了 Redis | 详见《Spring核心》
- Q:单元测试怎么保证隔离性?⭐ —— JUnit 5 每个用例新建测试实例;不共享可变状态、不依赖执行顺序、@BeforeEach 准备数据、外部依赖全部 mock 掉;出现”单独跑通、一起跑挂”就是有隐藏共享状态 | 详见《05-单元测试与工程质量》
- Q:覆盖率多少算合格?怎么看?⭐ —— JaCoCo 出行覆盖/分支覆盖;口径是”覆盖率不是目标,关键路径的分支覆盖才是”,团队更该看增量(diff)覆盖率,避免为凑数写无断言的假测试 | 详见《05-单元测试与工程质量》
- Q:你们团队怎么保证代码质量?(高频开放题)⭐ —— 四层:① 单测 + 集成测试(关键路径分支覆盖);② Code Review 强制 + PR 门禁;③ 静态扫描(Sonar/PMD/阿里规约插件)+ 统一格式化;④ CI 自动构建部署 + 线上监控告警与灰度 | 详见《04-简历与求职指南》
- Q:Lombok 的原理是什么?有什么坑?⭐ —— 编译期注解处理器修改 AST 生成方法字节码,不是运行时反射;坑:IDE 必须装插件、@Data 在实体上会生成含全部字段的 equals/hashCode、Javadoc 不含生成方法 | 详见《04-异常-IO-反射-注解》
- Q:日志为什么用 {} 占位符而不是字符串拼接?⭐ —— 拼接在日志级别关闭时仍会执行(无谓开销),占位符延迟求值;另外别用 System.out(无级别/时间/线程、无法被采集) | 详见《05-Elasticsearch》
- Q:阿里规约为什么禁止 POJO 布尔字段加 is 前缀?⭐ —— getter 为 isDeleted() 时部分框架推导属性名为 deleted,序列化/反序列化与 MyBatis 映射可能对不上导致字段丢失 | 详见《MySQL基础与SQL实战》
- Q:@Transactional 用在测试类上有什么副作用?⭐ —— 用例结束自动回滚(好事,保证隔离),但同库同连接会让真实提交行为测不到、内层事务被合并从而掩盖传播行为 bug、MyBatis 二级缓存与自增主键相关行为也不真实 | 详见《Spring核心》
推荐资料
| 资料 | 用来干什么 | 链接 |
|---|---|---|
| JavaGuide 单元测试 | JUnit5 + Mockito 的工程化用法与常见坑 | https://javaguide.cn/high-quality-technical-articles/focused/tech/2021-12-11-单元测试.html |
| pdai Spring 测试 | @SpringBootTest、切片测试、测试隔离的完整讲解 | https://pdai.tech/md/framework/spring-boot/x-boot-test.html |
| 廖雪峰 Spring Boot 实战 | 回到 todo-list 项目,把 Controller/Service 结构理清 | https://liaoxuefeng.com/books/java/springboot/index.html |
| 慕课 Java 单元测试实战 | 跟练 MockMvc/Testcontainers,补齐动手经验 | https://www.imooc.com/course/list?c=junit |
| 码工具 阿里 Java 开发手册 | 规约条目在线速查与自查清单 | https://www.matools.com/alibaba |