让 CI 不止于"能编译":给博客后端补上测试体系 | 一方天地
一个"虚假繁荣"的 CI
我的博客后端接入 Woodpecker CI 已经有一段时间了。但每次推送后流水线做的事情其实只有一件:mvn clean package -DskipTests。
注意那个 -DskipTests。能构建出 jar 包,说明不了什么——语法没错、依赖能解析,仅此而已。业务逻辑对不对、改一行代码会不会把点赞功能搞挂,CI 一概不知。这样的流水线更像个心理安慰:绿了也不一定好,红了肯定是编译错误。
最近给动态模块修 bug 的经历放大了这种不安:凌晨的全量校准任务会把 moment 图片的引用计数清零,这种问题如果有测试守着,提交阶段就会被拦下。于是决定把测试这块短板补上:单元测试锁关键逻辑,集成测试守核心链路,全部接进 CI。
这篇文章记录整个过程。坦白说,我以前从没写过单元测试,这次是边学边做,全程与 AI 结对完成——它讲概念、我做决策,代码一起写。
先定规矩:目录怎么分
测试代码全部放在 src/test/java,用命名后缀区分两类测试,而不是拆目录:
src/test/java/top/lantech/blog/
├── utils/
│ └── VisitorFingerprintTest.java ← 单元测试:*Test.java
├── service/impl/
│ └── LikeServiceImplTest.java ← 包结构镜像 main
└── integration/
└── ArticleListIT.java ← 集成测试:*IT.java
这不是洁癖,是 Maven 的插件分工:Surefire 插件(mvn test)默认只跑 *Test,Failsafe 插件(mvn verify)默认只跑 *IT。起对名字,CI 就能让毫秒级的单测和秒级的集成测试分层执行,不用任何额外配置。
单元测试:从纯函数到 Mock
第一课:纯函数测试
最简单的被测对象是纯函数,比如访客指纹工具——点赞去重靠它生成 key:
public static String of(String ip, String userAgent) {
return DigestUtils.sha256Hex((ip == null ? "" : ip) + "|" + (userAgent == null ? "" : userAgent));
}
不需要 Spring、不需要 Mock,直接调、直接断言。最有意思的是"黄金值"写法——把哈希结果硬编码进测试:
assertThat(fp).isEqualTo("02d64f67a174f5a83920b988534cd61021c9a1a770018b84fdb1f9d869a7110d");
这一行锁死的不只是算法,还有拼接格式:分隔符是 |、IP 在前。如果哪天有人把分隔符改成 :,线上所有点赞去重 key 全部失效——而这个测试会立刻红给你看。测试锁的是业务规则,不是代码行,这是我学到的第一件事。
第二课:Mock 外部依赖
真正的主战场是 Service 层。以点赞服务为例,它依赖 Redis 去重和 Mapper 计数。单测不碰真实中间件,用 Mockito 造假对象:
@ExtendWith(MockitoExtension.class) // 只初始化 @Mock,不启动 Spring 容器
class LikeServiceImplTest {
@Mock StringRedisTemplate stringRedisTemplate;
@Mock LikeTargetHandler articleHandler;
@Test
void like_incrementFails_rollsBackDedupKey() {
// 竞态场景:查到计数后、increment 前目标被删
when(articleHandler.selectCount(TARGET_ID)).thenReturn(5);
when(valueOperations.setIfAbsent(...)).thenReturn(true);
when(articleHandler.incrementCount(TARGET_ID)).thenReturn(0); // 影响 0 行
assertThatThrownBy(() -> likeService.like(...))
.isInstanceOf(BusinessException.class);
// 补偿逻辑必须执行:删占位 key,否则访客 30 天再也点不上赞
verify(stringRedisTemplate).delete(DEDUP_KEY);
}
}
这个用例让我理解了单测的真正价值:低成本制造异常路径。"查询和更新之间目标恰好被删"这种竞态,手动测试几乎不可能复现,Mock 只需一行 thenReturn(0)。而代码里那段防半截状态的回滚逻辑,从此有了保险。
另一个收获是断言的两个维度:
- 状态验证:返回值对不对、异常对不对(
assertThat/assertThatThrownBy) - 行为验证:对依赖的调用对不对——调没调、调几次、参数是什么(
verify/never/ArgumentCaptor)
很多关键逻辑(比如"验证码无论对错都必须删 key"这条安全语义)看返回值根本看不出来,只能靠行为验证焊死。
集成测试:真的把应用启动起来
单测之外,核心链路需要端到端验证。第一个集成测试选了文章列表接口 GET /article/list:
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("it")
class ArticleListIT {
@Autowired MockMvc mockMvc;
@Test
void list_customPaging_pageSizeRespected() throws Exception {
mockMvc.perform(get("/article/list").param("pageNum", "1").param("pageSize", "2"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.list.length()").value(lessThanOrEqualTo(2)));
}
}
@SpringBootTest 启动完整的 Spring 上下文,连真实的测试库(独立的 MySQL 和 Redis),请求从 Controller 一路走到 SQL。第一个测试特意只做只读操作——不写库就没有数据清理问题,断言只锁"结构"和"不变量"(分页参数被尊重、每篇文章都带处理后的标签数组),不依赖测试库里的具体数据,这样测试库内容变了测试也不会莫名红掉。
敏感配置的处理:application-it.yml 里有测试库密码,不入库。本地开发放 src/main/resources/(已 gitignore),CI 上由 runner 的持久目录在流水线里 cp 注入——和之前 SSH 私钥、Maven 缓存的处理方式一脉相承。
踩坑记录
1. AssertJ 被祖宗排除掉了。 pom 里 spring-boot-starter-test 的 <exclusions> 赫然躺着 assertj-core,大概是某次脚手架生成的遗留。删掉排除项即可。
2. Spring Boot 4 的模块化拆分。 @AutoConfigureMockMvc 不在 starter-test 里了,要单独引入 spring-boot-starter-webmvc-test,包路径也从 boot.test.autoconfigure... 变成了 boot.webmvc.test.autoconfigure...。搜索引擎里的答案基本都是 Boot 3 的,踩得很实。
3. woodpecker-cli 的 pipeline last 会 404。 想用 CLI 盯 CI 状态,pipeline ls 一切正常,pipeline last 却报 404,大概是服务端版本的 API 差异。绕开就行。
CI 成型
流水线从一步变成了三步,测试失败直接阻断构建与部署:
steps:
test: # 21 个单元测试,秒级
- ./mvnw test
it: # 3 个集成测试,连真实测试库
- cp /var/lib/woodpecker/config/application-it.yml src/main/resources/
- ./mvnw verify
build: # 打包(测试已过,skip)
deploy: # 仅 tag 触发
顺手还装了 woodpecker-cli 并登录,现在可以直接在终端里查流水线状态、拉日志——甚至 AI 助手也能通过它自己盯 CI 结果,形成闭环。
最终数据:21 个单元测试 + 3 个集成测试,流水线 #39 全绿。

写在最后
几个观念上的转变,比代码本身更值得记录:
- 测试是给业务规则上锁,不是追求覆盖率。 优先锁关键链路、防御性分支(补偿/回滚逻辑)、异常路径——这些地方最容易在重构时被无意改坏,也最难手动复现。
- 单测和集成测试是分工不是替代。 单测毫秒级、定位精准,管"逻辑对不对";集成测试秒级、覆盖全链路,管"接得通不通"。
- CI 的意义在于"敢改"。 以前改核心代码总有点心虚,现在推送后看着 test → it → build 依次变绿,那种踏实感是真实的。
接下来会慢慢给既有模块补测试:文章详情/归档的只读 IT、点赞接口的写操作 IT(正好练 @Transactional 回滚清理)、以及那次凌晨校准 bug 的防回归测试——修过的 bug,最配拥有一个测试。