测试金字塔
单元/集成/E2E 分层;为何需要测试。
测试金字塔
这一课你将能做到
- 画出单元 / 集成 / E2E 三层金字塔,说清每层「测什么、不测什么、多快、多贵」
- 把 CodeForge backend 现有的
SubmissionServiceTest、MemoryRequestRateLimiterTest 等测试归到正确的层
- 本地跑通
mvn test 记录耗时,并写出「改动类型 → 补哪一层」的三条决策规则
为什么要学
想象一个真实场景:你改了 SubmissionService.submit 的语言白名单,把 "sql" 从支持列表里移除。没有测试时你只能手点:登录、进课时、切换语言、提交代码、看报错——五分钟过去你不确定测全了没有,十分钟后你开始敷衍。人话:测试是可无限次重按的验证按钮,按一次毫秒级,结论永远一致;手点是人肉测试,慢、漏、不可重复。
CodeForge 是 backend(Spring Boot + MySQL + Redis + sa-token)加 platform(Next.js)的双端系统,任何一次改动的爆炸半径都比单文件大。.github/workflows/ci.yml 里 backend 跑 mvn -B verify、platform 跑 npm test,正是金字塔在持续集成里的落地:底层多、顶层少,反馈快、成本可控。
面试角度:「你们项目的测试怎么分层?为什么不全写 E2E?」是后端高频题,本课给的就是标准答案的骨架。学完你应能解释「只写 E2E 为什么是反模式」,并知道下一课从哪里动手写第一个单元测试。
还有一层直觉:测试分层不是「职称高低」,而是反馈回路长短。单元失败通常指向一个类;E2E 失败可能指向后端、前端、数据、环境四者之一。所以健康团队本能地「多写能快速红绿的测」——这不是偷懒,是工程理智。
先建立图像
/\ E2E(少):真实浏览器走用户路径,分钟级,易 flaky
/ \
/----\ 集成(中):多组件 + 真 MySQL/Redis,秒级
/ \
/--------\ 单元(多):单类纯逻辑 + mock 依赖,毫秒级
/__________\
| 层级 | 测什么 | 典型工具 | 速度 | CodeForge 例子 |
|---|
| 单元 | 一个类的分支与边界 | JUnit 5 + Mockito | 毫秒 | SubmissionServiceTest(mock Mapper) |
| 集成 | SQL、事务、Bean 装配 | @SpringBootTest + Testcontainers | 秒 | UserMapper 对真库的 insert/select 往返 |
| E2E | 用户关键路径 | Playwright | 分钟 | 注册 → 登录 → 提交代码看到「排队中」 |
金字塔底部宽、顶部窄:Service 单测应占大多数;浏览器冒烟只守「注册、登录、提交判题」这类命门。人话:用便宜的探针量体温,别每次都做全身 CT。
跟着做
步骤 1:给现有测试归类
打开 backend/src/test/java/com/codeforge。对每个 *Test.java 问两句:
- 有没有
@SpringBootTest 或真数据源?→ 偏集成
- 只有
@ExtendWith(MockitoExtension.class) + @Mock?→ 单元
你会看到 SubmissionServiceTest、、 全是第二种——它们正是健康的金字塔底部。把归类结果记成三行笔记:「N 个 Service 单测、0 个集成、0 个 E2E」。
RoadmapServiceTest
MemoryRequestRateLimiterTest
步骤 2:跑一次,用秒表建立直觉
看 Surefire 汇总:Tests run、Failures、总耗时。纯 Mockito 用例应在几秒到几十秒内结束。再对比:
verify 多走打包与报告阶段,记下两者耗时差。人话:日常改代码用 test,提交前用 verify 对齐 CI。
步骤 3:亲手写一个「最底层」的测试
新建练习类 PasswordRules.java(规则镜像本站注册逻辑:密码 6–64 位):
public class PasswordRules {
public static boolean isValid(String password) {
return password != null && password.length() >= 6 && password.length() <= 64;
}
}
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class PasswordRulesTest {
@Test
void 六位密码合法() {
assertThat(PasswordRules.isValid("123456")).isTrue();
}
@Test
void 五位与空值都不合法() {
assertThat(PasswordRules.isValid("12345")).isFalse();
assertThat(PasswordRules.isValid(null)).isFalse();
}
}
运行 mvn test -Dtest=PasswordRulesTest,应秒级绿。这就是底层测试的体验:无容器、无网络、无数据库,红绿之间只隔一次保存。
步骤 4:画本站金字塔草图
| 层 | 你放的内容 |
|---|
| 顶 | 注册→登录→提交判题;访客打开课时页 |
| 中 | UserMapper / SubmissionMapper 对真 MySQL 的往返;Redis 限流读写 |
| 底 | 现有全部 *ServiceTest、PasswordRulesTest 这类纯函数测 |
对照原则:改校验分支 → 先补底;改 SQL/迁移 → 中层;改整页路由体验 → 才考虑顶层。
步骤 5:写下决策规则
- 改
if / 校验 / 白名单 → 先补或改单元(JUnit + Mockito)
- 改 SQL / 迁移 / 缓存 TTL / 事务 → 补集成(真库或 Testcontainers)
- 改整页路由或跨端联调假设 → 才考虑 E2E 冒烟
若你发现自己「一改校验就去写 Playwright」,停下来:那是把钉子当锤子用。健康团队把 80% 的回归信心放在底部。
讲清楚要点
- 测试是可重复的验证,不是上线仪式:改代码的频率远高于发版,验证必须自动化,否则必然漏测。
- 反馈回路决定分层:单元失败指向一个类;E2E 失败面横跨前后端与环境。失败要能从底层先收窄,定位成本才低。
- E2E 只守关键路径:颜色、边距、每个按钮的文案不适合放在顶层——慢且脆,每多一条就多一份维护税。
- 集成层不可省但需克制:mock 挡住了 SQL、事务、唯一索引的真相,这些只能靠真库证明;但数量要少,用命名与 CI 分轨控制成本。
- 覆盖率数字不看断言质量:绿测 + 具体断言优先于「百分百行覆盖」,第 10 课展开。
- 本地命令与 CI 一致:
mvn -B verify 与 npm test,否则「我这能过」合上去红。
你可能会懵的地方
-
「单元」和「集成」的边界到底在哪?
只测一个类、依赖全是假的 → 单元;至少两个真实组件一起跑(Service + 真 Mapper + 真库)→ 集成。MockMvc 不启浏览器、可 mock Service,是「HTTP 层的快速契约测」,介于两者之间偏下。
-
为什么「只写 E2E」是反模式?
慢、难定位、对环境敏感。一次失败可能是后端 500、前端路由、还是选择器过期——你不知道。金字塔把细节验证沉到底部。
-
CodeForge 现在集成与 E2E 很少,是不健康吗?
健康状态是底部先厚。现有 Mockito Service 测是正确起点;Testcontainers、Playwright 按风险逐步加,而不是一次堆满。
-
测试和文档是什么关系?
submit_非法语言返回400 这样的测试名 + 断言本身就是不会过期的规格说明,比 Wiki 更可信——Wiki 会忘改,红灯不会撒谎。
-
前端要不要另建一座金字塔?
要。Vitest 组件测是前端底部,Playwright 是顶部,形状与 Java 侧同构,第 8、9 课展开。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 只写 E2E 冒烟 | 慢、脆、难定位 | 底多顶少,E2E 只守命门 |
| 只测 happy path | 边界回归全漏 | 每类至少补一条失败路径 |
为覆盖率写 assertTrue(true) | 假绿 | 断言具体状态码 / 字段 / 异常 |
| 本地命令与 CI 不一致 | 幽灵红灯 | 对齐 mvn -B verify / npm test |
| 用集成测代替所有单测 | 反馈变慢,没人愿跑 | 纯逻辑单测,SQL 才集成 |
卡点急救
mvn test 显示 Tests run: 0:测试类没以 *Test 结尾、不在 src/test/java,或类不是包可见以上级别;先对齐 Surefire 默认约定再排查依赖。
- 全套件突然要几分钟:有人把
@SpringBootTest 加进了纯逻辑测试。搜日志里的 Started CodeForgeApplication,找到肇事先,把它降级回 Mockito 或切片。
- Windows 终端里中文方法名乱码:给 Surefire 配
<argLine>-Dfile.encoding=UTF-8</argLine>,或直接在 IDE 里运行该类。
面试常问
-
什么是测试金字塔?为什么底部要宽?
单元多、集成中、E2E 少的三层模型。底部宽是因为单元最快最稳、失败定位最准;顶部贵且脆,只放关键路径。倒过来(冰淇淋筒)会让 CI 巨慢且 flaky,团队最终不敢跑测试。
-
单元测试和集成测试怎么取舍?
问一句:「把依赖 mock 掉之后,我还信不信这个结论?」业务分支、边界校验 → 单元;SQL、事务、缓存 TTL、Bean 装配 → 集成。以 CodeForge 判题接口为例:语言白名单校验用 Mockito 单测,Mapper 对 MySQL 的写入往返用 Testcontainers。
-
你们项目的测试是怎么落地的?
按 CodeForge 答:backend 每次提交跑 mvn -B verify(JUnit 5 + Mockito 单测为主),platform 跑 npm test;CI 红灯阻断合并,慢测(容器 IT、E2E)分轨到主分支或夜间任务,不让每个 PR 排队。
自测三问
- 用一句话解释:为什么金字塔要「底宽顶窄」?
SubmissionServiceTest mock 掉了 Mapper,它属于哪一层?为什么不算集成?
- 你改了 Redis 限流的 TTL 逻辑,按决策规则该补哪一层?
小结一句话
多写快测、少写慢测、E2E 只守命门——分层是成本与信心的平衡术。
延伸