跳转至

单元测试

Spring Boot Web 项目的“单测”其实不止一种。
平时大家口头上都叫“单元测试”,但实际混在一起的通常有 4 层:纯单元测试、Spring 切片测试、集成测试、端到端测试。不同层级里,是否 mock、是否启动 Spring、是否真的访问数据库、是否真的走 HTTP,都不一样。Spring 官方也明确把 testing 分成这些不同方向:既有 true unit tests,也有基于 TestContext、MockMvc、WebTestClient 等的更高层测试。


一、先把概念分层,不然越写越乱

1)纯单元测试

目标是只验证一个类本身的业务逻辑
特点是:

  • 不启动 Spring 容器
  • 不连数据库
  • 不发 HTTP
  • 依赖全都 mock 掉

例如测试 UserService

  • UserRepository mock
  • RemoteClient mock
  • RedisTemplate mock

这种测试最快,最稳定,最适合覆盖复杂分支逻辑。Spring 文档里也强调,true unit tests 运行极快,因为没有运行时基础设施需要启动。

适合测:

  • 参数校验分支
  • 状态流转
  • 异常处理
  • 重试/降级逻辑
  • 组合多个依赖后的业务判断

不适合测:

  • Controller 请求映射是否正确
  • JSON 序列化是否符合预期
  • SQL/JPA/MyBatis 是否真能跑
  • Spring 事务/AOP/缓存注解是否生效

2)Web 层切片测试

目标是只测 Controller / MVC 行为,不测整个应用。
最典型就是你现在贴的这种 MockMvc

Spring 官方说明里,MockMvc 是服务端测试框架,能执行完整的 Spring MVC 请求处理流程,但使用的是 mock request/response,对应的是“不启动真正 HTTP server”的 Web 层测试。@WebMvcTest 也会自动配置 MockMvc

比如你这类代码:

return mvc.perform(MockMvcRequestBuilders.post(uri)
.contentType(MediaType.APPLICATION_JSON)
.content(body)
.header("Accept-AccessKey", headers.get("Accept-AccessKey"))
.header("Accept-Time", headers.get("Accept-Time"))
.header("Accept-Sign", headers.get("Accept-Sign")))
.andExpect(status().isOk())
.andReturn();

它测的是:

  • URL 是否映射正确
  • Header / Query / Body 是否绑定正确
  • 参数校验是否生效
  • Controller Advice / 异常处理是否正确
  • 返回 JSON 结构是否符合预期
  • Filter / Interceptor / ArgumentResolver 是否按预期参与

但它通常不该真的去打数据库
因为 Web 层切片测试的重点不是“底层数据真不真”,而是“接口层行为对不对”。

这时 Controller 下游的 Service 往往用 @MockBean 或导入一个简单 stub。


3)数据层切片测试

目标是测 Repository / DAO / Mapper。
如果是 JPA,最典型是 @DataJpaTest。Spring Boot 官方文档说明,@DataJpaTest 只启用 Data JPA 相关自动配置,默认是事务性的,测试后回滚,并且默认会替换成内存数据库;如果不想替换,可以用 @AutoConfigureTestDatabase 覆盖。

适合测:

  • SQL / JPQL / QueryDSL / Mapper XML 是否正确
  • 唯一索引、排序、分页
  • 实体映射
  • 事务回滚行为

这里一般不 mock 数据库,因为你测的核心就是数据访问本身。


4)集成测试

目标是验证多个层一起工作是否正常。
通常是:

  • 启动 Spring Boot
  • 可能加载真实 Bean
  • 可能连真实数据库,或 Testcontainers 起一个临时数据库
  • 可能通过 HTTP 客户端调用接口
  • 可能只 mock 少量外部依赖

这类测试最接近真实运行环境。
如果你要验证:

  • Controller + Service + Repository 整链路
  • 事务是否真的生效
  • Spring AOP / Cache / Security / Filter 真正集成是否正确
  • JSON、数据库、业务逻辑整体是否一致

那就该写集成测试,而不是假装写“单元测试”。

Testcontainers 官方就明确推荐用真实服务做测试依赖,而不是用 mock 或内存替代,尤其是数据库特性和生产库不一致时;它支持用容器启动一次性数据库。


5)端到端测试

目标是像真实用户那样,从外部去调用整个系统。
通常会:

  • 启动真正端口
  • 通过真实 HTTP 发请求
  • 连真实或近似真实环境
  • 可能还会调用消息队列、缓存、外部服务

这类最慢,但最能发现“只有真实环境才会出”的问题。


二、什么时候 mock,什么时候不要 mock

这个最关键。可以记一个简单原则:

原则一:测谁,就别 mock 谁;mock 它的下游

比如你在测 UserService,那就不要把 UserService 自己 mock 掉,而是 mock:

  • UserRepository
  • RemoteClient
  • RedisClient

因为你想验证的是 UserService 的逻辑。


原则二:你的测试目的决定 mock 深度

场景 A:只想验证业务分支

那就大量 mock。

比如:

  • 下游返回成功
  • 下游超时
  • 下游抛异常
  • 数据库查不到数据

这种用 Mockito 最合适,写得快,执行也快。Mockito 本身就是 Java 的 mock 框架。

场景 B:想验证框架集成是否真的正确

那就少 mock,甚至不 mock。

比如:

  • @Transactional 是否真的回滚
  • JPA 查询是否真能跑
  • Spring MVC 参数绑定是否正确
  • Jackson 序列化字段名是否正确
  • Filter/Interceptor 顺序是否正确

这些如果全 mock,就测不到真实问题。


原则三:数据库相关,优先“真测”而不是 mock

Repository / Mapper / SQL / 事务 / 索引 / 方言差异,这些都非常不适合 mock。

因为 mock 数据库只能证明“你假设它会返回什么”,不能证明“SQL 真能跑”。

特别是下面这些,更该用真实数据库或 Testcontainers:

  • MySQL 函数
  • JSON 字段查询
  • 索引命中
  • 悲观锁/乐观锁
  • 事务隔离级别
  • 分页排序
  • 方言差异(MySQL vs H2)

Testcontainers 官方也明确提到,很多 DAO 测试依赖数据库特性时,内存库并不能准确模拟真实数据库。


原则四:外部系统一般 mock 或 stub

比如:

  • 第三方 HTTP 服务
  • 短信服务
  • 支付服务
  • 对象存储
  • MQ 生产者
  • Redis 分布式锁
  • Nacos / Consul / 外部配置中心

原因是这些依赖:

  • 不稳定
  • 有副作用
  • 不适合在测试里真实调用

这类通常 mock 或者做本地 stub server。


三、发起 API 请求,到底有多少种方式

在 Spring Boot 里,常见至少有这几种:

1)MockMvc

最常见,适合 Spring MVC。
不启动真实 HTTP server,但会走 MVC 处理链。Spring 官方就是这么定义的。

适合:

  • Controller 测试
  • Header / Body / 参数绑定
  • JSON 断言
  • 状态码断言

你的例子就属于这一类。


2)WebTestClient

Spring 官方说明,WebTestClient 既可以做端到端 HTTP 测试,也可以在不启动 server的情况下测试 Spring MVC 或 WebFlux。

它的定位有点像:

  • 在 WebFlux 里很常见
  • 在 MVC 里也能用
  • API 比较现代,链式风格更统一

适合想统一测试风格的人。


3)TestRestTemplate

Spring Boot 官方把它定义为:适合集成测试的 RestTemplate 替代品,而且对 4xx/5xx 是 fault-tolerant,不会直接抛异常,便于断言响应。

通常配合:

@SpringBootTest(webEnvironment = RANDOM_PORT)

然后真起服务,再走 HTTP 调用。

适合:

  • 更接近真实 HTTP
  • 想验证端口、序列化、Filter、容器行为
  • 集成测试

4)RestAssured

你没提,但很多团队也会用。
更偏接口测试风格,语义比较像“接口自动化”。


5)直接调用 Controller 方法

严格说这都不算 API 请求。
这是纯 Java 调用,只适合极轻量地测某个 Controller 方法,但测不到:

  • 请求映射
  • 参数绑定
  • Jackson
  • Filter/Interceptor
  • 异常转换

所以文章里可以提一句:这不是真正的接口测试,只是方法测试。


四、你举的两类代码,本质上是什么

第一类:performWithSign(...)

这是典型 Web 层测试封装

它做的事情是:

  • 构造签名 header
  • 用 MockMvc 发 POST
  • 断言 HTTP 200

适合抽成公共方法,因为很多接口只是 URI、body、header 不同。

这种测试主要是在验证:

  • 接口签名校验是否通过
  • 请求能否进入 Controller
  • 返回结构是否正常

第二类:deleteUserData()

这类已经明显不是纯单元测试了,更像集成测试 / 接口集成测试

因为它:

  1. 先准备数据 addAnnounce(userId)
  2. 发接口请求 postFormReq(...)
  3. 等异步完成 sleep()
  4. 再查数据 checkAnnounceValue(...)
  5. 再次发请求验证硬删除
  6. 再次查库验证结果

这类测试特点是:

  • 有前置数据准备
  • 有真实状态变化
  • 有数据库断言
  • 可能还带异步副作用

这时如果你还把它叫“单元测试”,其实已经不太准确了。更准确应叫:

  • 接口集成测试
  • 业务流程测试
  • 带数据库验证的集成测试

五、Service 层测试怎么分

1)纯业务 Service:优先纯单元测试

例如:

class OrderService {
private final UserRepository userRepository;
private final CouponService couponService;
private final PayClient payClient;
}

如果你要测的是:

  • 用户不存在时抛异常
  • 优惠券过期时拒绝下单
  • 支付失败时回滚订单状态

那就 mock 下游,直接测 Service。

这是最标准的 service unit test。


2)事务型 Service:建议补一层集成测试

如果这个 service 上有:

  • @Transactional
  • 多表写入
  • 乐观锁/悲观锁
  • 延迟加载
  • 事件发布
  • AOP 审计

那只写 mock 单测不够。
还应该有一层:

  • 启动 Spring
  • 用真实数据库
  • 真调用 service

因为很多问题只有真实 Spring + 真实 DB 才能暴露。


3)强依赖静态工具类的 Service:先考虑重构,再考虑静态 mock

例如:

String sign = SignUtils.md5(data);
long ts = System.currentTimeMillis();
String traceId = TraceContext.getTraceId();

这时候有三种做法,优先级从高到低:

第一优先:重构

把静态依赖改成可注入组件,例如:

  • Clock
  • SignService
  • TraceIdProvider

这样测试最自然。

第二优先:Mockito static mock

Mockito 已支持 static mocking,MockedStatic 是官方能力。它是有作用域的,且只影响创建它的线程,使用完要关闭。

适合:

  • 老代码暂时改不动
  • 只是少量静态方法
  • 想尽量少引入额外框架

第三优先:PowerMock

PowerMock 当然能做静态 mock,它的 wiki 里也明确给了 mockStatic 的用法。

但现在一般不推荐新项目继续依赖 PowerMock,原因很现实:

  • 它和 JUnit / Mockito / Java 新版本兼容问题多
  • 社区活跃度和维护状态不理想
  • 很多过去用 PowerMock 的场景,Mockito 现在已经能做了

GitHub 上也能看到 PowerMock 维护活跃度长期偏弱,以及和 Mockito 4 兼容问题的讨论。

所以文章里可以写成一句很实用的话:

PowerMock 是“历史包袱解决器”,不是新项目首选。能重构就重构,不能重构优先 Mockito 的 static mock。


六、什么时候“真的和数据库交互”

你提到这一点很重要。很多人容易把“只要碰数据库,就不是单元测试”说得太绝对。更准确的说法是:

适合真连数据库的场景

  • Repository / Mapper 测试
  • Service 上有事务语义
  • SQL 复杂
  • 要验证唯一索引、外键、锁
  • 要验证数据状态流转
  • 要验证懒加载、级联保存
  • 要验证真实数据库方言

不适合真连数据库的场景

  • 只是想测 if/else 业务分支
  • 只是想测参数拼装
  • 只是想测某个异常分支
  • 下游数据库结果只影响逻辑分支,不影响 SQL 本身正确性

一句话:

如果数据库本身是被测对象的一部分,就真连;如果数据库只是为了给逻辑分支凑数据,就优先 mock。


七、Spring Boot 项目里常见测试注解,应该怎么理解

@WebMvcTest

只加载 MVC 相关组件,专注 Web 层,通常配合 MockMvc。Spring Boot 官方就是这么定义的。

适合:

  • Controller
  • 参数绑定
  • 返回 JSON
  • 异常处理

@SpringBootTest

加载完整 Spring Boot 应用上下文。
适合集成测试。

常见组合:

  • @SpringBootTest
  • @SpringBootTest(webEnvironment = RANDOM_PORT)

前者可以不走真实 HTTP;后者通常配合 TestRestTemplate 或其他客户端走真实端口。TestRestTemplate 官方就明确是为 integration tests 准备的。


@DataJpaTest

JPA 切片测试。默认事务回滚,默认倾向内存库,可调整。


@MockBean

用于 Spring 测试上下文里,把某个 Bean 替换成 mock。
适合 @WebMvcTest@SpringBootTest 下只 mock 部分下游。


八、如何选择:给你一个实战决策表

1)只测业务逻辑

选:JUnit + Mockito
不要启动 Spring,不要连 DB。

2)只测 Controller 接口行为

选:@WebMvcTest + MockMvc
下游 Service 用 @MockBean

3)测 Repository / JPA / SQL

选:@DataJpaTest
必要时配真实数据库或 Testcontainers。

4)测 Service + DB + 事务

选:@SpringBootTest + 真实数据库/Testcontainers

5)测真实 HTTP 整链路

选:@SpringBootTest(webEnvironment = RANDOM_PORT) + TestRestTemplate / WebTestClient

6)代码里有静态工具类,暂时改不动

选:Mockito static mock
少量历史遗留再考虑 PowerMock。


九、文章可以怎么写

你这篇文章,我建议标题就别叫“Java 单元测试大全”,那样太泛。
更贴近实战的标题可以是:

《Spring Boot Web 项目测试分层实践:MockMvc、Service Mock、真实数据库与静态方法测试》

文章结构可以直接这样:

1. 为什么 Spring Boot 项目里的“单元测试”经常被叫乱

先讲:

  • 大家都叫单测
  • 其实分纯单元、切片、集成、E2E
  • 不同层 mock 策略完全不同

2. 先建立测试分层模型

讲四层:

  • Unit Test
  • Slice Test
  • Integration Test
  • End-to-End Test

3. Web 接口测试有哪些写法

讲:

  • MockMvc
  • WebTestClient
  • TestRestTemplate
  • RestAssured
  • 直接调 Controller 方法为什么不算真正接口测试

4. Service 层测试怎么做

讲:

  • 纯业务逻辑:mock 下游
  • 带事务/DB:补集成测试
  • 外部依赖:mock 或 stub

5. 什么时候要真实数据库

讲:

  • JPA/SQL/事务/锁/方言差异
  • 为什么 H2 不一定靠谱
  • 为什么 Testcontainers 更适合真实集成测试

6. 静态方法怎么测

讲:

  • 最优先是重构
  • 其次 Mockito static mock
  • 最后 PowerMock 兜底
  • 为什么不建议新项目重度依赖 PowerMock

7. 测试金字塔与团队落地建议

讲建议比例:

  • 多写纯单测
  • 适量 Web 切片测试
  • 少量关键链路集成测试
  • 更少量 E2E

8. 常见误区

可以列这几个:

  • 误区 1:凡是测试都叫单元测试
  • 误区 2:Mock 越多越专业
  • 误区 3:接口测试就一定要起端口
  • 误区 4:Repository 测试也可以全 mock
  • 误区 5:PowerMock 是静态方法测试标配

十、给你一版文章结尾总结

你可以直接用:

在 Spring Boot Web 项目中,“单元测试”并不是单一写法,而是一组按层次组织的测试手段。

纯单元测试强调快速、隔离、只测业务逻辑;Web 层切片测试强调接口映射、参数绑定和返回结构;数据层测试强调 SQL 和 ORM 映射的真实性;集成测试则关注多个模块在真实 Spring 环境中的协作。

是否 mock,不应凭习惯决定,而应由“当前测试的目标”决定:

  • 想测业务分支,就 mock 下游;
  • 想测框架集成,就少 mock;
  • 想测数据库行为,就用真实数据库;
  • 遇到静态方法,优先重构,其次用 Mockito static mock,最后再考虑 PowerMock。

真正成熟的测试体系,不是所有测试都写成一种样子,而是让不同层级的测试各司其职。