主题
界面
主题
前两节会看覆盖率了。本节把"标准"变成机器能执行的事——覆盖率低于阈值时让构建失败,而不是靠人记得去看报告。
本节有一个实测踩坑要讲:JaCoCo 插件级的阈值配置会静默失效,门禁看起来在工作、实际上什么都没检查。这是质量门禁最危险的失败模式。
完成本节后,你应该能够:
没有自动化门禁的覆盖率,结局通常是这样的:
第 1 周:团队约定"覆盖率不低于 80%"
第 3 周:有人提交了 72%,评审时被提醒了一次
第 6 周:新功能赶进度,覆盖率掉到 65%,没人提
第 12 周:覆盖率 41%,报告再也没人打开约定靠人记,就会随人的注意力衰减。
门禁的作用是把约定变成构建的一部分——它不会忘记、不会妥协、每次都执行。
但门禁本身也是代码,也会配错。 本节的实测表明:配错的门禁比没有门禁更危险,因为它给你一种"有保护"的错觉。
| 层次 | 时机 | 检查什么 | 失败后果 |
|---|---|---|---|
| 提交前 | 本地 mvnw test | 测试通过 | 开发者自己修 |
| CI 门禁 | 推送后 | 测试 + 覆盖率阈值 + 静态检查 | PR 无法合并 |
| 发布前 | 打 tag / 发布流水线 | 集成测试 + 端到端 + 冒烟 | 阻断发布 |
关键设计:覆盖率门禁放在"CI"这一层,而不是"提交前"——因为:
-DskipTests),CI 上不行。| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 整体阈值 | 全项目 ≥ 80% | 简单 | 老项目永远达不到,只能被迫降低标准 |
| 只降不升 | 记录基线,不得低于基线 | 务实、逐步改善 | 需要维护基线文件 |
| 新增代码阈值 | 新提交的代码 ≥ 85% | 最有效,不背历史包袱 | 需要工具支持(如 diff-cover) |
实践建议:
阈值定多少(参考区间):
| 指标 | 保守 | 常见 | 激进 |
|---|---|---|---|
| 行覆盖 | 70% | 80% | 90%+ |
| 分支覆盖 | 60% | 75% | 85%+ |
不建议设 100%:最后的几个百分点成本陡增(T1-013 的误区一)。
| 方式 | 做法 | 特点 |
|---|---|---|
| 插件级 | jacoco-maven-plugin 的 check 目标 | 与构建集成紧;但配置容易静默失效(见 5.4) |
| 脚本级 | 读 jacoco.csv,自己判断,退出码非 0 即失败 | 逻辑显式可见、可打印实测值、可本地运行 |
本项目的选择:脚本级。理由是实测出来的——下面这段值得完整读完。
按官方文档的写法在 testing-unit 模块里配了 check:
<execution>
<id>check-coverage</id>
<phase>verify</phase>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule implementation="org.jacoco.maven.RuleConfiguration">
<element>BUNDLE</element>
<limits>
<limit implementation="org.jacoco.report.check.Limit">
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>1.00</minimum> <!-- 要求 100%,实际只有 91.7% -->
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>预期:构建失败(分支覆盖 91.7% < 100%)。
实际输出:
[INFO] --- jacoco:0.8.15:check (check-coverage) @ testing-unit ---
[INFO] Loading execution data file ...\target\jacoco.exec
[INFO] Analyzed bundle 'testing-unit' with 8 classes
[INFO] All coverage checks have been met. ← 认为"满足"
[INFO] BUILD SUCCESS ← 构建成功门禁完全没有生效——而且不会报任何错。
排查过程(三种配置状态的实测):
| 配置 | 结果 |
|---|---|
无 implementation 提示 | "All coverage checks have been met."(静默通过) |
rule + limit 都加 implementation(猜的类名 LimitConfiguration) | Cannot load implementation hint(报错,至少可见) |
rule=RuleConfiguration + limit=org.jacoco.report.check.Limit(正确类名) | "All coverage checks have been met."(仍然静默通过) |
rule=org.jacoco.report.check.Rule | class Rule cannot be cast to RuleConfiguration(类型错误) |
结论:在当前版本组合下,这个配置没有可靠地工作,而且失败方式是"静默通过"。
这是质量门禁最危险的失败模式:
一个看起来生效、实际什么都不检查的门禁,比没有门禁更糟——它给你一种被保护的感觉。
由此得到一条硬规则:
任何门禁配好后,必须故意制造一次失败,确认它真的会红。
具体做法:把阈值临时调到 100%(或删掉一个关键测试),跑一次门禁,看到它失败,再把阈值调回。没做过这个动作的门禁,都应该被当作"未验证"。
这条规则不只适用于覆盖率——任何"检查类"的配置(lint、依赖漏洞扫描、契约检查)都一样。
思路:不用插件的 check,而是读完 CSV 自己判断。
"""覆盖率门禁:读取 JaCoCo CSV 报告,按阈值校验,不达标时退出码为 1。"""
REPORT = Path("labs/testing-unit/target/site/jacoco/jacoco.csv")
COUNTERS = ("LINE", "BRANCH")
def read_totals(report):
"""返回 {"LINE": (covered, total), "BRANCH": (covered, total)}。"""
covered = dict.fromkeys(COUNTERS, 0)
total = dict.fromkeys(COUNTERS, 0)
with report.open(encoding="utf-8") as handle:
for row in csv.DictReader(handle):
for counter in COUNTERS:
hit = int(row[f"{counter}_COVERED"])
covered[counter] += hit
total[counter] += hit + int(row[f"{counter}_MISSED"])
return {counter: (covered[counter], total[counter]) for counter in COUNTERS}
def main() -> int:
...
print("覆盖率门禁检查")
failures = []
for counter in COUNTERS:
covered, total = totals[counter]
actual = ratio(covered, total)
limit = thresholds[counter]
passed = actual >= limit
print(f" {counter:<7}{covered}/{total} = {actual:.1%}(下限 {limit:.0%})→ {'通过' if passed else '不通过'}")
if not passed:
failures.append(f"{counter} 覆盖率 {actual:.1%} 低于下限 {limit:.0%}")
if failures:
print("\n门禁未通过:")
for item in failures:
print(f" - {item}")
return 1
print("\n门禁通过")
return 0四个优点:
| 优点 | 说明 |
|---|---|
| 逻辑可见 | 判断规则就是几行 Python,没有隐藏的配置魔法 |
| 打印实测值 | 通过或失败都告诉你"实际是多少、下限是多少" |
| 本地可跑 | 开发者提交前可以自己跑一次 |
| 可验证 | 改一下阈值参数就能确认它真的会失败 |
- name: 运行测试(含覆盖率采集)
run: cd labs && ./mvnw -B -ntp test
- name: 覆盖率门禁
run: python scripts/check_coverage.py --line 0.90 --branch 0.85
- name: 归档覆盖率报告
if: always() # 即使门禁失败也要归档,便于排查
uses: actions/upload-artifact@v4
with:
name: jacoco-report
path: labs/testing-unit/target/site/jacoco/三个细节:
if: always():报告一定要归档——门禁失败时,报告才是最有用的东西;--line / --branch 写成命令行参数:调整阈值不需要改脚本代码(阈值是团队约定,会变)。报告归档的价值:PR 上能看到覆盖率报告的下载链接,评审者可以直接点进去看"哪些行没覆盖"。
# 用默认阈值(行 90% / 分支 85%)——应当通过
python scripts/check_coverage.py
# 把分支阈值临时提高到 95%——应当失败
python scripts/check_coverage.py --branch 0.95关键在第二条命令:它是"门禁验证"这个动作本身。任何门禁都该先这样试一次。
- run: python scripts/check_coverage.py --line ${{ vars.LINE_THRESHOLD }} --branch ${{ vars.BRANCH_THRESHOLD }}把阈值放进 CI 变量:调整阈值不需要改代码、不需要提 PR——但它仍然是显式的团队约定(改变量会有记录)。
--line 1.0 --branch 1.0后果:团队会开始为了满足门禁而写无意义的测试,或者把难测的代码排除在统计外(T1-013 的误区三)。
修复:阈值设在"当前水平 + 5% 左右",让它"跳一跳够得着"。
文件:scripts/check_coverage.py(完整实现见仓库)
运行命令与真实输出:
cd <仓库根目录>
python -X utf8 scripts/check_coverage.py覆盖率门禁检查
LINE 73/73 = 100.0%(下限 90%)→ 通过
BRANCH 33/36 = 91.7%(下限 85%)→ 通过
门禁通过
退出码=0故意把阈值提高,验证门禁真的会失败:
python -X utf8 scripts/check_coverage.py --branch 0.95覆盖率门禁检查
LINE 73/73 = 100.0%(下限 90%)→ 通过
BRANCH 33/36 = 91.7%(下限 95%)→ 不通过
门禁未通过:
- BRANCH 覆盖率 91.7% 低于下限 95%
退出码=1这两次运行合起来才是一次完整的门禁验证:
| 运行 | 退出码 | 证明了什么 |
|---|---|---|
| 默认阈值 | 0 | 正常情况不误报 |
| 提高阈值 | 1 | 门禁真的在检查(不是静默通过) |
注意输出里的数字:33/36 = 91.7%——门禁失败信息直接给出了"实际值、当前值、缺多少",排查时不需要再去翻报告。
这也解释了为什么用脚本而不是插件:插件的静默失效(5.4)让人无法确认门禁是否在工作,而脚本的行为是完全可见的。
check_coverage.py 的 read_totals 里断点,观察它逐行读 CSV 累加的过程——每一行 CSV 对应一个类;return 1 处断点,观察 failures 列表里的内容——这就是"失败原因清单";jacoco-report,打开 index.html——确认"门禁失败 + 报告可下载"这个组合是完整的。现象:配置文件里写了阈值,构建一直绿,没人知道它到底有没有在工作。
修复:配好后立刻做一次"故意失败"验证(见 5.4 的硬规则)。
扩展:把这个验证写进门禁的文档或注释,让后来者知道"这个门禁验证过、验证方式是这样"。
--line 0.95 --branch 0.90 # 当前只有 70% / 60%后果:第一次提交就红,团队第一反应不是"补测试",而是"把阈值调低"——门禁从此失去权威。
修复:从当前水平开始(或略低于它),然后逐步提高。门禁要能通过才有意义。
后果:开发者只知道"覆盖率不够",不知道缺在哪里,只能本地重跑一遍。
修复:if: always() + 上传 artifact(CI 片段里已包含)。
REPORT = Path("labs/testing-unit/target/site/jacoco/jacoco.csv")问题:模块改名或增加模块后脚本就失效。
修复:支持扫描多个报告(本项目当前只有一个模块有覆盖率,保持简单即可;模块变多时应改成遍历)。
后果:开发者提交后才发现不达标,来回改好几次。
修复:把门禁命令加进本地的"提交前检查"清单(T1-014 的三档策略)——门禁要能被本地预演。
目标:搭一个真正生效的覆盖率门禁。
步骤:
jacoco.exec);INSTRUCTION),阈值设为 90%;验收方式:能展示第 2 步的失败输出与第 6 步的记录。
扩展任务:给"只降不升"策略做一个极简实现——把每次通过的覆盖率写入 coverage-baseline.json,下次运行时比较,低于基线就失败(提示:需要处理"首次运行"的情况)。
基础题
代码阅读题
def main() -> int:
if failures:
print("门禁未通过")
return 1
print("门禁通过")
return 0编程题
--baseline 参数,指向一个 JSON 文件(如 coverage-baseline.json);综合任务
提示 1(方向):第 4 题的关键是"CI 通过退出码判断成败"。
提示 2(关键点):第 6 题的"首次运行"要特别处理——没有基线时应该"记录并放行",否则第一次就会失败。
提示 3(接近答案):第 7 题的"豁免机制"通常用 CI 变量或提交信息里的标记(如 [skip-coverage]),并且必须有记录——豁免的本质是"让例外可见",而不是"让例外消失"。
<rules> 在配置不完整时会静默通过(输出 "All coverage checks have been met.")。任何门禁都必须经过"故意制造失败"的验证。"只对新增代码设要求"绕开了这个问题:它不管历史欠账,只保证"这次改动带来了测试"。长期执行下去,整体覆盖率会自然上升——而且提升是来自"新代码有测试",而不是"老代码刷数字"。
第 4 题:
| 退出码 | CI 的判定 | 开发者的感受 |
|---|---|---|
| 0 | 步骤成功,继续执行 | 绿 |
| 1 | 步骤失败,流水线中断 | 红,PR 无法合并 |
这就是"脚本式门禁"的全部机制——它不比插件高级,只是把判断逻辑写在了明面上。
第 5 题:未捕获异常时,Python 以退出码 1 结束(非零)。
CI 会判定失败 ✓
这是好事:"脚本自己出问题"和"覆盖率不达标"都表现为失败——宁可误报,也不要静默通过。
对比插件的行为:插件配置错误时可能什么都不做还是报成功——所以脚本式门禁在"失败可见性"上更安全。
顺带一个实践建议:如果希望区分"门禁不通过"和"脚本异常",可以用不同的退出码(如 1 = 不达标、2 = 脚本错误),CI 上就能看出区别。
"""覆盖率门禁(含"只降不升"策略)。
用法:
python scripts/check_coverage.py --baseline coverage-baseline.json
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
from pathlib import Path
REPORT = Path("labs/testing-unit/target/site/jacoco/jacoco.csv")
COUNTERS = ("LINE", "BRANCH")
def read_totals(report: Path) -> dict[str, tuple[int, int]]:
if not report.exists():
raise SystemExit(f"找不到覆盖率报告:{report}\n请先执行:cd labs && ./mvnw test")
covered = dict.fromkeys(COUNTERS, 0)
total = dict.fromkeys(COUNTERS, 0)
with report.open(encoding="utf-8") as handle:
for row in csv.DictReader(handle):
for counter in COUNTERS:
hit = int(row[f"{counter}_COVERED"])
covered[counter] += hit
total[counter] += hit + int(row[f"{counter}_MISSED"])
return {counter: (covered[counter], total[counter]) for counter in COUNTERS}
def ratio(covered: int, total: int) -> float:
return 1.0 if total == 0 else covered / total
def main() -> int:
parser = argparse.ArgumentParser(description="覆盖率门禁")
parser.add_argument("--line", type=float, default=0.90)
parser.add_argument("--branch", type=float, default=0.85)
parser.add_argument("--baseline", type=Path, default=None,
help="基线文件(只降不升)")
args = parser.parse_args()
totals = read_totals(REPORT)
actual = {counter: ratio(*totals[counter]) for counter in COUNTERS}
thresholds = {"LINE": args.line, "BRANCH": args.branch}
print("覆盖率门禁检查")
failures = []
for counter in COUNTERS:
covered, total = totals[counter]
limit = thresholds[counter]
passed = actual[counter] >= limit
print(f" {counter:<7}{covered}/{total} = {actual[counter]:.1%}(绝对下限 {limit:.0%})→ {'通过' if passed else '不通过'}")
if not passed:
failures.append(f"{counter} 覆盖率 {actual[counter]:.1%} 低于下限 {limit:.0%}")
# 基线检查(只降不升)
if args.baseline is not None:
baseline = None
if args.baseline.exists():
baseline = json.loads(args.baseline.read_text(encoding="utf-8"))
if baseline is None:
print(f"\n首次运行:写入基线 {args.baseline}")
else:
for counter in COUNTERS:
previous = baseline.get(counter, 0.0)
if actual[counter] < previous - 1e-9:
message = f"{counter} 覆盖率从基线 {previous:.1%} 降到 {actual[counter]:.1%}"
print(f" 基线检查:{message} → 不通过")
failures.append(message)
else:
print(f" 基线检查:{counter} {previous:.1%} → {actual[counter]:.1%} → 通过")
args.baseline.write_text(
json.dumps(actual, indent=2, ensure_ascii=False), encoding="utf-8")
if failures:
print("\n门禁未通过:")
for item in failures:
print(f" - {item}")
return 1
print("\n门禁通过")
return 0
if __name__ == "__main__":
sys.exit(main())验证两步:
# ① 首次运行:生成基线
python -X utf8 scripts/check_coverage.py --baseline coverage-baseline.json
# ② 删掉一个测试后运行:应当因"低于基线"失败
python -X utf8 scripts/check_coverage.py --baseline coverage-baseline.json"只降不升"的核心设计:
| 情况 | 行为 |
|---|---|
| 没有基线文件 | 记录当前值并放行(首次运行不能失败,否则门禁永远上不了线) |
| 低于基线 | 失败(即使仍高于绝对阈值) |
| 高于基线 | 通过,并更新基线(这样下次的起点更高) |
注意 - 1e-9 这个细节:浮点数比较不能直接用 <,否则 0.9166666 与 0.9166667 之间的微小差异会导致误报。
质量门禁方案:
## 一、门禁清单
| # | 门禁 | 触发 | 阈值 | 失败处理 |
|---|------|------|------|---------|
| 1 | 测试通过 | 推送 / PR | 全绿 | 阻断合并,开发者修 |
| 2 | 覆盖率(绝对) | PR | 行 ≥ 90%、分支 ≥ 85% | 阻断合并 |
| 3 | 覆盖率(只降不升) | PR | 不低于基线 | 阻断合并,需说明原因 |
| 4 | 静态检查(可选) | PR | 无 error 级问题 | 阻断合并 |
| 5 | 构建产物(可选) | tag / 发布 | jar 能生成、能启动 | 阻断发布 |
## 二、执行时机
- **提交前(本地)**:门禁 1(快)
- **CI(推送后)**:门禁 1 → 2 → 3 → 4(并行执行 2/3/4 以节省时间)
- **发布前**:门禁 1-5 + 集成测试
## 三、豁免机制
**允许豁免的场景**:
- 生产紧急修复(hotfix 分支)
- 门禁本身的 bug 修复
**豁免方式**:
- PR 标题或 commit message 加 `[skip-coverage]`
- **必须有记录**:豁免原因写在 PR 描述里,并开一个后续修复的 issue
**谁批准**:至少一名 team lead
**关键原则**:豁免的本质是"**让例外可见**",不是"让例外消失"——
门禁系统应该能统计"上个月豁免了几次、原因是什么"。
## 四、CI 配置片段
```yaml
jobs:
test:
steps:
- run: cd labs && ./mvnw -B -ntp test
- run: python scripts/check_coverage.py --line 0.90 --branch 0.85 --baseline coverage-baseline.json
- if: always()
uses: actions/upload-artifact@v4
with:
name: jacoco-report
path: labs/testing-unit/target/site/jacoco/| 门禁 | 怎么验证它真的生效 |
|---|---|
| 测试通过 | 故意让一个断言失败,确认流水线红 |
| 覆盖率(绝对) | 把阈值临时提到 100%,确认失败(本项目已实测) |
| 覆盖率(只降不升) | 删掉一个测试后运行,确认因"低于基线"失败 |
| 静态检查 | 故意引入一个明显问题(如未使用变量),确认失败 |
| 构建产物 | 故意破坏打包配置,确认发布流程被阻断 |
验证要记录在案:在门禁配置的注释或团队文档里写明"何时验证过、怎么验证的"。
**这份方案的三个设计要点**:
1. **门禁分档**:不是所有门禁都要阻断合并——**测试与覆盖率阻断,静态检查可以只警告**(避免一开始就激起抵触);
2. **豁免要可见**:紧急情况一定有,**关键是让它留下痕迹**;
3. **验证方法必须写出来**:这是本项目实测出的教训——**没验证过的门禁等于没有**。
**最后一句建议**:**先把门禁定在"当前水平",让它无痛上线;然后每季度提高一点。** 一个长期运行、从不被绕过的 80% 门禁,比一个上线三天就被团队投票关掉的 95% 门禁有用得多。TA 单元测试专项的主体内容到此完成(剩余两节依赖 Spring 章节):
接下来可以进入 S3 企业开发基础:MySQL、Spring、Git 与 Linux(该阶段页面将在产出时建立)。