跳到正文
Java 入门到面试指南
返回

05-单元测试与工程质量

阶段:框架篇 | 建议时长: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 官方)vs TestRestTemplate / RestTestTemplate(真起随机端口 @SpringBootTest(webEnvironment = RANDOM_PORT),能测到过滤器与拦截器的完整链路)。
  • 整链路集成测试的注解组合:@SpringBootTest + @AutoConfigureMockMvc——前者启动完整容器(Controller→Service→Mapper 真实装配),后者自动配置 MockMvc 让你不必真起端口也能发请求。只测 Web 层用 @WebMvcTest(更快的切片测试,Service 要 mock)。
  • ⭐@MockBean 会让上下文缓存失效从而重启容器,大量使用会显著拖慢测试;纯单测就别起 Spring。
  • 测试隔离:@Transactional 标在测试类/方法上,测试结束自动回滚(这是 Spring 测试最常用的一招);但注意它回滚不了 Redis、消息队列,也会让”事务传播行为”测不出来(内层事务被合并)。

数据库测试的三条路

  1. @Transactional 自动回滚 —— 最省事,但依赖同一连接、测不到真实提交效果。
  2. 独立测试库 + 每轮 clean(或 Flyway/@Sql 脚本重建数据)—— 团队 CI 常用。
  3. ⭐Testcontainers(了解即可):测试时用 Docker 真起一个 MySQL/Redis 容器,跑完销毁。兼顾”真环境”和”可重复”,代价是慢、CI 需要 Docker。
  4. 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:常用注解与原理

注解生成什么注意点
@Datagetter/setter/toString/equals/hashCode/@RequiredArgsConstructor⭐实体上慎用:equals/hashCode 会带上所有字段(含大集合),可能栈溢出;JPA 实体建议 @Getter @Setter
@Builder建造者,Todo.builder().id(1L).build()与 Jackson 反序列化配合要加 @AllArgsConstructor
@Slf4jprivate static final Logger log = LoggerFactory.getLogger(X.class)少写一行样板
@RequiredArgsConstructorfinal 字段的构造器⭐配合构造器注入的最佳写法,替代 @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 与 RoundingModedouble 无法精确表示 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