综合练习
这一课你将能做到
- 为判题接口交付 ≥4 个分层测试:SubmissionService Mockito 单测 + SubmissionController MockMvc 契约测
- 用定向
mvn test -Dtest=... 与提交前 mvn verify 证明本地绿,并解释每一层的取舍理由
- 推送后确认
ci.yml 相关 job 全绿,并写出「新人如何只跑本模块测试」的三行说明
为什么要学
前面各课是零件;本课要求你串成可交付物。人话:会背金字塔不够,要能在真实模块上留下绿测,并让 CI 替你守门。
目标对象是 CodeForge 的判题接口(SubmissionController + SubmissionService + SubmissionMapper 边界)——它是「校验 + 状态机 + 落库」的浓缩样本,四层测试各有一席之地。注意现有 SubmissionServiceTest 已覆盖「构造记录并置 pending」和「按用户课时过滤」,你的任务是补缺口而不是复制粘贴:非法输入分支、静态登录态、HTTP 契约、错误体形状。
完成后,你应能向同伴演示「测在哪、怎么跑、CI 看哪」——这正是面试里「聊聊你们项目的测试体系」的现场答案。
先建立图像
交付清单:
[1] SubmissionServiceExtraTest Mockito:非法语言 / 非法课时
[2] SubmissionControllerSliceTest MockMvc:200 JSON / 400 错误体 / 401
[3](选做)UserMapperIT Testcontainers:唯一索引
[4](选做)VerdictBadge.test.tsx Vitest:徽章渲染
验收:本地定向绿 → mvn verify 绿 → push → CI 绿 → 三行说明
| 层 | 工具 | 最低条数 |
|---|
| Service | @Mock + @InjectMocks | 2 |
| HTTP | MockMvc + @MockitoBean + mockStatic | 2 |
| 集成 / 前端 | Testcontainers IT / Vitest | 选做各 1 |
跟着做
步骤 1:盘点现有测试,选定缺口
打开 backend/src/test/java/com/codeforge/service/SubmissionServiceTest.java,笔记两列:
- 已有:
submit_构造记录并置pending、listByUserAndLesson_按用户和课时过滤
- 缺口:非法语言、空代码、非法课时、HTTP 400 错误体、未登录 401
选定两个 Service 场景 + 两个 HTTP 场景为必做。可以加强既有断言,但不要重复完全相同的用例。
步骤 2:Service 层两个缺口场景(Mockito)
@ExtendWith(MockitoExtension.class)
class SubmissionServiceExtraTest {
@Mock SubmissionMapper submissionMapper;
@InjectMocks SubmissionService service;
@Test
void submit_不支持的动画语言被拒绝且不落库() {
// 语言白名单外的输入:校验前置,无需 stub
assertThatThrownBy(() -> service.submit(3L, 5L, "brainfuck", "code"))
.isInstanceOf(BusinessException.class)
.hasMessage("不支持的语言类型");
verify(submissionMapper, never()).insert(any(Submission.class));
}
@Test
void submit_课时非法时拒绝并给出提示() {
assertThatThrownBy(() -> service.submit(3L, 0L, "java", "code"))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("课时参数");
verify(submissionMapper, never()).insert(any(Submission.class));
}
}
mvn test -Dtest=SubmissionServiceExtraTest
应看到:两例秒级绿。方法名、异常类型按你仓库真实代码对齐,意图(非法输入 → 拒绝 + 不落库)不变。
步骤 3:HTTP 契约两个场景(MockMvc 切片)
@WebMvcTest(SubmissionController.class)
class SubmissionControllerContractTest {
@Autowired MockMvc mockMvc;
@MockitoBean SubmissionService submissionService;
@Test
void 提交成功返回200与pending记录() throws Exception {
try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
stp.when(StpUtil::getLoginIdAsLong).thenReturn(3L);
Submission s = new Submission();
s.setId(10L);
s.setStatus("pending");
when(submissionService.submit(3L, 5L, "java", "class A {}"))
.thenReturn(s);
mockMvc.perform(post("/submissions")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"lessonId":5,"language":"java","code":"class A {}"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(10))
.andExpect(jsonPath("$.status").value("pending"));
}
}
@Test
void 业务异常返回400与统一错误体() throws Exception {
try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
stp.when(StpUtil::getLoginIdAsLong).thenReturn(3L);
when(submissionService.submit(any(), any(), any(), any()))
.thenThrow(new BusinessException("不支持的语言类型"));
mockMvc.perform(post("/submissions")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"lessonId":5,"language":"brainfuck","code":"x"}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("不支持的语言类型"));
}
}
}
这一组测试证明的是 GlobalExceptionHandler 的翻译契约——前端解析错误提示全靠它。
步骤 4:提交前全量对齐 + 选做加分项
cd backend
mvn verify # 快测 + IT + JaCoCo 报告,与 CI 同命令
# 选做 A:Testcontainers 唯一索引(见第 6 课 UserMapperIT)
# 选做 B:platform 组件测(见第 8 课 VerdictBadge)
cd ../platform && npm test
选做项做之前先想清楚它守的是哪层缺口,说不清理由的选做项宁可不加——这句话本身就是金字塔训练。
步骤 5:推送并做 CI 自检 + 写三行说明
推送功能分支,打开 GitHub Actions:
- backend job:
mvn -B verify 绿
- platform job:
npm test 绿(若你动了前端)
只跑判题相关:mvn test -Dtest=Submission*
提交前全量:mvn verify
看 CI:.github/workflows/ci.yml 的 backend / platform job
这三行是给下一个接手的人的第一份礼物——它让「我该怎么验证我的改动」不再口口相传。
步骤 6:对照评分表自检(自学用)
| 项 | 标准 |
|---|
| 命名 | @Test 名描述行为,失败报告可读 |
| 独立 | 无顺序依赖、无共享脏状态 |
| 速度 | Service 层不起全上下文 |
| 断言 | 具体字段 / 异常消息,不只 verify |
| 分层 | 每条测试能说清「为什么放这层」 |
| CI | 相关 job 绿 |
任何一条不过关,回到对应课重练——综合练习的意义是把薄弱环节暴露出来。
讲清楚要点
- 分层职责不同,所以 Service 测与 MockMvc 测都要交:只交一层刷数量,验收看的是结构完整性。
- 命名是给失败报告读的:
业务异常返回400与统一错误体 让 CI 红灯自解释——综合交付按这个标准执行。
- 测试必须独立:
@BeforeEach 或纯 stub,禁止用例间顺序依赖——顺序在本地和 CI 上都可能不同。
- 速度决定愿不愿意跑:Service 测不要随手
@SpringBootTest,容器留给 HTTP/集成层。
- 断言质量重于数量:只 verify 无业务 assert 的空壳测是负资产——它让覆盖率虚高、让审查者误信。
- CI 是最终裁判:本地 verify 与 workflow 命令对齐,「推上去才红」是最贵的反馈路径。
你可能会懵的地方
-
现有测试已经覆盖了我写的场景,还要交吗?
换缺口:边界、错误码、缺字段。练习的是完整闭环,不是重复造轮子;加强既有断言也是合格交付。
-
路径 / 字段与示例不一致?
以仓库真实 API 为准调整路径与 JSON 字段,四条用例的意图不变:非法拒绝、契约锁定。
-
platform 没动,CI platform 却红了?
先确认是否与你改动相关(rebase 最新 main 再看);主线既有问题在 PR 里说明归属,别默默修不属于你的锅。
-
怎样算完成?
必做 4 条 @Test 全绿 + 能讲解每条分层理由 + CI 相关 job 绿。选做 IT 与组件测是加分项,不是门槛。
错法对照
| 错法 | 问题 | 正解 |
|---|
四条测全是 @SpringBootTest 打真库 | 慢、难维护 | Service 纯 Mockito |
| 只 verify 无 assert | 假绿 | 断言返回值 / 状态码 / 字段 |
方法名 test1、t1 | 失败不可读 | 行为命名 |
| 本地定向绿就直接推 | CI 惊魂 | 提交前 mvn verify |
| 红灯删测过关 | 失去练习意义 | 修实现或修断言 |
卡点急救
- 切片测报缺 Bean:对照第 4 课,把 Controller 构造器依赖逐个
@MockitoBean;StpUtil 静态调用配 mockStatic。
- 定向命令报 No tests found:
-Dtest= 的模式与实际类名不匹配(Submission* 覆盖不到 SubmissionControllerContractTest 时改用 Submission*Test 或逗号列出)。
- 本地绿 CI 红:读第一个失败堆栈(第 11 课五步法),90% 是本地没跑全量
verify 或依赖版本漂移。
面试常问
-
用一段话介绍你们项目的测试体系(现场模板)。
「CodeForge 分四层:Service 层用 JUnit 5 + Mockito 做毫秒级单测,覆盖校验与状态分支;HTTP 层用 MockMvc + @MockitoBean 锁定状态码与统一错误体契约;数据层用 Testcontainers 起真实 MySQL 验证唯一索引与往返;前端用 Vitest + Testing Library 测组件行为,Playwright 只守注册、判题等三到五条命门。CI 里 PR 跑 mvn verify 与 npm test 阻断合并,慢测分轨夜间。」——按自己项目改细节,骨架可直接背。
-
给判题接口写测试,你会先写哪几条?为什么?
顺序:非法输入拒绝(最便宜、最高频回归)→ 成功路径落库状态 → HTTP 契约(状态码 + 错误体)→ 唯一性 / 数据往返(集成)。排序逻辑:成本从低到高,回归风险从高频到低频。
-
怎么向新人解释「该补哪层测试」?
给决策规则而不是给答案:改校验分支 → Mockito 单测;改 SQL/迁移 → Testcontainers IT;改 HTTP 错误翻译 → MockMvc;改整页交互 → 组件测或 E2E。新人拿着规则自己能走,比每问一次答一次可持续。
自测三问
- 你交付的 4 条测试分别守金字塔的哪一层?各证明了什么单层证不了的事?
- 为什么「存在 + 不存在」比两条 happy path 更有回归价值?
- 新人只想跑判题相关测试,你给的 Maven 命令是什么?
小结一句话
Mockito 守业务、MockMvc 守契约、Testcontainers 守数据、Vitest/Playwright 守界面、CI 守合并——testing-essentials 以一次完整交付闭环收束。
延伸