单元测试红了半年:静态 Bean 定位器让部署一直被跳过
模块改了三个月,发布记录里部署成功的次数是零。test 一直红,deploy 一直 skipped,没人注意两者的区别。根因是切面里那句 SpringUtils.getBean。
有个后端模块改了三个月,发布记录里"部署成功"的次数是零。流水线卡片一直是红的,大家看一眼就关掉:又红了,测试环境的老毛病。真正要命的是 red 的那一步是 test,后面的 deploy 一直是 skipped。也就是说这三个月里,这个模块的"发布"从来没有执行过,只是没人注意到 skipped 和 success 的区别。
我是从一次"改了不生效"开始查的。改一个校验逻辑,重新提交,等流水线,看部署日志——什么都没有,因为部署根本没跑。往上翻,发现 backend-test 从半年前就一直在同一个用例上失败。
一、先分清 red 和 skipped
流水线的表达很容易骗人:整条流水线是红的,我会下意识认为"是测试的问题,不影响部署"。但如果 deploy 依赖 test 且 test 失败,deploy 的状态是 skipped,不是"失败",在概览页上它和"成功但没输出"长得几乎一样。
判断方法很土但有效:直接看每个 step 的退出状态和耗时。
# 用 CI 的 API 拉一次构建的步骤明细
curl -s -H "Authorization: Bearer $CI_TOKEN" \
"$CI_URL/api/repos/$REPO/builds/$BUILD" \
| python3 -c 'import json,sys
d=json.load(sys.stdin)
for s in d["stages"]:
for st in s["steps"]:
print("%-14s %-8s %5ss" % (st["name"], st["state"], st.get("duration",0)//1000000000))'
打出来是这样:
detect success 3s
deps success 19s
test failure 142s
deploy skipped 0s
smoke skipped 0s
test 142s failure,deploy skipped 0s。从这一刻起,"发布成功"这四个字就不成立了。
二、把失败搬到本地
CI 只给了一段被截断的堆栈,本地复现最快:
mvn -q -pl module-auth -am test \
-Dtest=AuthUtilDelegationTest -DfailIfNoTests=false 2>&1 | tail -40
失败信息不是断言不通过,而是空指针,位置在切面里:
java.lang.NullPointerException
at com.example.auth.AuthUtil.getCurrentUser(AuthUtil.java:41)
at com.example.auth.PreAuthorizeAspect.before(PreAuthorizeAspect.java:63)
第 41 行是 SpringUtils.getBean(AuthLogic.class).currentUser()。在完整启动的应用里这行永远没问题,因为容器里有 AuthLogic;但在纯 JUnit 环境里,SpringUtils 的静态 applicationContext 从来没有被赋过值,getBean 直接 NPE。
三、为什么测试里给 mock 也没用
第一反应是把 AuthLogic mock 掉。写了 @MockBean 才发现根本进不去——@MockBean 需要 Spring 上下文,而这个用例是纯 JUnit,连上下文都没有;退一步改 Mockito.mock() 也没用,因为 SpringUtils.getBean 走的是静态字段,不经过任何可替换的注入点。
这就是静态 Bean 定位器的根本问题:它把"依赖"藏进了一个全局可变状态,调用方看起来无参,实际上强依赖运行时的容器状态。它在生产里好写,在测试里不可控,而且状态跨用例传染——一个用例如果真把上下文塞进去了,后面的用例会莫名其妙地"能用"。
我试过在 @BeforeAll 里手工塞一个 context,可行性有,但代价是每个涉及切面的测试类都要重复这段,而且要 mock 的 Bean 会越滚越多。这条路只能算止血。
四、三种修法的取舍
把可选方案列成表,再决定动哪里:
| 方案 | 改动面 | 测试可写性 | 风险 |
|---|---|---|---|
| 测试里反射塞静态 context | 每个测试类 | 差,样板代码多 | 静态状态污染,用例顺序敏感 |
全量改 @SpringBootTest |
无 | 好 | 每个用例起容器,测试从 142s 涨到十分钟量级 |
| 调用方改成构造注入 | 业务类若干 | 好 | 改动面大,切面类要改造成 Bean |
| 保留静态入口,内部委托给可注入的门面 | 小 | 好 | 需要一次兼容改造 |
我们最后选了第四条:SpringUtils 保留,但内部不再是"直接拿 context",而是委托给一个 BeanResolver 接口,默认实现从 context 取,测试里可以替换成假的。
public final class SpringUtils implements BeanResolver {
private static volatile BeanResolver delegate = new ContextBeanResolver();
public static <T> T getBean(Class<T> type) {
return delegate.getBean(type);
}
// 仅供测试装配,生产代码不要调用
static void setDelegate(BeanResolver resolver) { delegate = resolver; }
}
测试里就一行:
@BeforeEach
void setUp() {
SpringUtils.setDelegate(type -> new AuthLogic());
}
这样既没有引入 Spring 上下文,也没有在每个业务类上动刀。代价是要接受"存在一个可被替换的静态入口",这对工具类来说是可以接受的边界。
五、顺手补上两道闸
修完用例只是让流水线变绿,真正防止复发的是两道闸。
第一道是让"跳过部署"这件事变得刺眼。把 deploy 的依赖从 test 改成 test 与 package 都成功,并且在 deploy 步骤开头加一句显式断言:
if [ "$CI_PREV_STEP_STATUS" != "success" ]; then
echo "上游步骤未成功,拒绝部署"
exit 1
fi
第二道是给关键模块加一条"部署完成后核对版本"的检查,和镜像 digest 比对同一思路:Pod 里的构建号必须等于本次提交短 sha。没有这一步,skipped 还会继续伪装成成功。
顺便说一个和这件事相关的坑:这个模块的镜像构建步骤一直是绿的,因为它只做 docker build,不跑测试。所以"镜像构建成功"给人一种"代码没问题"的错觉。构建成功只证明能编译,不证明行为正确。
六、这件事真正的成本
从发现到修完,代码改动不到三十行。真正的成本在别处:三个月里这个模块的若干次"修复",都是基于线上旧版本行为做的判断,有人甚至在旧版本上验证过"问题已解决"。这些结论现在都要重新确认。
我后来在值班手册里加了一条:发布记录里出现连续 skipped,等同于发布中断,按故障处理。这条比任何技术方案都管用,因为它把"没人看"变成了"必须看"。
小结
- 流水线红不等于"只是测试挂了",先看
deploy是 failure 还是 skipped,后者意味着从未部署。 - 纯 JUnit 里调用
SpringUtils.getBean必然 NPE,@MockBean和mock()都救不了静态字段。 - 静态 Bean 定位器把依赖藏进全局状态,测试不可控、用例顺序敏感,是这类失败的根因。
- 最小改动是保留静态入口、内部委托给可替换的门面,测试里换实现,不引入 Spring 上下文。
- 给"上游未成功"加显式断言,并核对 Pod 里的构建号,才能让 skipped 不再伪装成成功。
正在加载评论…