Skip to content

JaCoCo 阈值与 CI 质量门禁 ​

T1-018必学进阶约 45 分钟JDK 21测试专项 A:单元测试 · 覆盖率与门禁

1. 本节定位 ​

前两节会看覆盖率了。本节把"标准"变成机器能执行的事——覆盖率低于阈值时让构建失败,而不是靠人记得去看报告。

本节有一个实测踩坑要讲:JaCoCo 插件级的阈值配置会静默失效,门禁看起来在工作、实际上什么都没检查。这是质量门禁最危险的失败模式。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出质量门禁的三个层次与各自的触发时机;
  • 说出三种阈值策略,并知道为什么"只对新增代码设要求"最实用;
  • 识别"门禁静默失效"这个陷阱,并用"故意制造失败"验证门禁真的生效;
  • 用脚本实现一个可见、可控、可验证的覆盖率门禁;
  • 把门禁接入 CI 并归档报告。

3. 前置知识检查 ​

  • 会配置 JaCoCo 并读报告吗?(T1-004)
  • 知道覆盖率会高估吗?(T1-013)

4. 为什么需要这个知识 ​

没有自动化门禁的覆盖率,结局通常是这样的:

text
第 1 周:团队约定"覆盖率不低于 80%"
第 3 周:有人提交了 72%,评审时被提醒了一次
第 6 周:新功能赶进度,覆盖率掉到 65%,没人提
第 12 周:覆盖率 41%,报告再也没人打开

约定靠人记,就会随人的注意力衰减。

门禁的作用是把约定变成构建的一部分——它不会忘记、不会妥协、每次都执行。

但门禁本身也是代码,也会配错。 本节的实测表明:配错的门禁比没有门禁更危险,因为它给你一种"有保护"的错觉。

5. 核心概念 ​

5.1 门禁的三个层次 ​

层次时机检查什么失败后果
提交前本地 mvnw test测试通过开发者自己修
CI 门禁推送后测试 + 覆盖率阈值 + 静态检查PR 无法合并
发布前打 tag / 发布流水线集成测试 + 端到端 + 冒烟阻断发布

关键设计:覆盖率门禁放在"CI"这一层,而不是"提交前"——因为:

  1. 本地跑得越少越快越好(T1-005 的分层执行);
  2. 门禁是"团队约定",由团队的基础设施执行更权威;
  3. 本地可以临时绕过(-DskipTests),CI 上不行。

5.2 三种阈值策略 ​

策略做法优点缺点
整体阈值全项目 ≥ 80%简单老项目永远达不到,只能被迫降低标准
只降不升记录基线,不得低于基线务实、逐步改善需要维护基线文件
新增代码阈值新提交的代码 ≥ 85%最有效,不背历史包袱需要工具支持(如 diff-cover)

实践建议:

  1. 新项目:直接设整体阈值(如行 80%、分支 75%);
  2. 老项目:先上一版"只降不升",把当前数字记成基线,然后逐步提高;
  3. 所有项目:把"新增代码覆盖率"作为长期目标——它衡量的正是"这次的改动有没有带测试"。

阈值定多少(参考区间):

指标保守常见激进
行覆盖70%80%90%+
分支覆盖60%75%85%+

不建议设 100%:最后的几个百分点成本陡增(T1-013 的误区一)。

5.3 两种实现方式 ​

方式做法特点
插件级jacoco-maven-plugin 的 check 目标与构建集成紧;但配置容易静默失效(见 5.4)
脚本级读 jacoco.csv,自己判断,退出码非 0 即失败逻辑显式可见、可打印实测值、可本地运行

本项目的选择:脚本级。理由是实测出来的——下面这段值得完整读完。

5.4 实测:插件级门禁的静默失效 ​

按官方文档的写法在 testing-unit 模块里配了 check:

xml
<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%)。

实际输出:

text
[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.Ruleclass Rule cannot be cast to RuleConfiguration(类型错误)

结论:在当前版本组合下,这个配置没有可靠地工作,而且失败方式是"静默通过"。

这是质量门禁最危险的失败模式:

一个看起来生效、实际什么都不检查的门禁,比没有门禁更糟——它给你一种被保护的感觉。

由此得到一条硬规则:

任何门禁配好后,必须故意制造一次失败,确认它真的会红。

具体做法:把阈值临时调到 100%(或删掉一个关键测试),跑一次门禁,看到它失败,再把阈值调回。没做过这个动作的门禁,都应该被当作"未验证"。

这条规则不只适用于覆盖率——任何"检查类"的配置(lint、依赖漏洞扫描、契约检查)都一样。

5.5 脚本式门禁:本项目采用的方式 ​

思路:不用插件的 check,而是读完 CSV 自己判断。

python
"""覆盖率门禁:读取 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,没有隐藏的配置魔法
打印实测值通过或失败都告诉你"实际是多少、下限是多少"
本地可跑开发者提交前可以自己跑一次
可验证改一下阈值参数就能确认它真的会失败

5.6 接入 CI ​

yaml
- 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/

三个细节:

  1. 门禁与测试分开两步:这样失败时能明确区分"测试挂了"还是"覆盖率不够";
  2. if: always():报告一定要归档——门禁失败时,报告才是最有用的东西;
  3. --line / --branch 写成命令行参数:调整阈值不需要改脚本代码(阈值是团队约定,会变)。

报告归档的价值:PR 上能看到覆盖率报告的下载链接,评审者可以直接点进去看"哪些行没覆盖"。

6. 递进示例 ​

6.1 最小示例:验证门禁真的会失败 ​

powershell
# 用默认阈值(行 90% / 分支 85%)——应当通过
python scripts/check_coverage.py

# 把分支阈值临时提高到 95%——应当失败
python scripts/check_coverage.py --branch 0.95

关键在第二条命令:它是"门禁验证"这个动作本身。任何门禁都该先这样试一次。

6.2 业务示例:把阈值参数化 ​

yaml
- run: python scripts/check_coverage.py --line ${{ vars.LINE_THRESHOLD }} --branch ${{ vars.BRANCH_THRESHOLD }}

把阈值放进 CI 变量:调整阈值不需要改代码、不需要提 PR——但它仍然是显式的团队约定(改变量会有记录)。

6.3 反例:把门禁设成"必须 100%" ​

python
--line 1.0 --branch 1.0

后果:团队会开始为了满足门禁而写无意义的测试,或者把难测的代码排除在统计外(T1-013 的误区三)。

修复:阈值设在"当前水平 + 5% 左右",让它"跳一跳够得着"。

7. 完整可运行代码 ​

文件:scripts/check_coverage.py(完整实现见仓库)

运行命令与真实输出:

powershell
cd <仓库根目录>
python -X utf8 scripts/check_coverage.py
text
覆盖率门禁检查
  LINE   73/73 = 100.0%(下限 90%)→ 通过
  BRANCH 33/36 = 91.7%(下限 85%)→ 通过

门禁通过
退出码=0

故意把阈值提高,验证门禁真的会失败:

powershell
python -X utf8 scripts/check_coverage.py --branch 0.95
text
覆盖率门禁检查
  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)让人无法确认门禁是否在工作,而脚本的行为是完全可见的。

8. Debug 观察点 ​

  1. 在 check_coverage.py 的 read_totals 里断点,观察它逐行读 CSV 累加的过程——每一行 CSV 对应一个类;
  2. 在 return 1 处断点,观察 failures 列表里的内容——这就是"失败原因清单";
  3. 在 CI 上观察:故意提交一个删掉测试的改动,确认 PR 变红——这是端到端的门禁验证;
  4. 查看 CI 的 Artifact 页面,下载 jacoco-report,打开 index.html——确认"门禁失败 + 报告可下载"这个组合是完整的。

9. 常见错误 ​

错误 1:门禁配好了但从未验证过(本节核心) ​

现象:配置文件里写了阈值,构建一直绿,没人知道它到底有没有在工作。

修复:配好后立刻做一次"故意失败"验证(见 5.4 的硬规则)。

扩展:把这个验证写进门禁的文档或注释,让后来者知道"这个门禁验证过、验证方式是这样"。

错误 2:阈值一步到位 ​

python
--line 0.95 --branch 0.90        # 当前只有 70% / 60%

后果:第一次提交就红,团队第一反应不是"补测试",而是"把阈值调低"——门禁从此失去权威。

修复:从当前水平开始(或略低于它),然后逐步提高。门禁要能通过才有意义。

错误 3:门禁失败但不归档报告 ​

后果:开发者只知道"覆盖率不够",不知道缺在哪里,只能本地重跑一遍。

修复:if: always() + 上传 artifact(CI 片段里已包含)。

错误 4:把报告路径写死 ​

python
REPORT = Path("labs/testing-unit/target/site/jacoco/jacoco.csv")

问题:模块改名或增加模块后脚本就失效。

修复:支持扫描多个报告(本项目当前只有一个模块有覆盖率,保持简单即可;模块变多时应改成遍历)。

错误 5:只在 CI 上跑门禁,本地完全没概念 ​

后果:开发者提交后才发现不达标,来回改好几次。

修复:把门禁命令加进本地的"提交前检查"清单(T1-014 的三档策略)——门禁要能被本地预演。

10. 动手实验 ​

目标:搭一个真正生效的覆盖率门禁。

步骤:

  1. 运行第 7 节的两条命令,确认退出码分别是 0 和 1;
  2. 删除一个测试方法,重跑门禁,观察数字与退出码的变化(记得先删 jacoco.exec);
  3. 把删掉的测试补回来,确认门禁恢复通过;
  4. 修改脚本:加上第三个指标(如指令覆盖 INSTRUCTION),阈值设为 90%;
  5. 用一条命令同时跑测试与门禁(模拟 CI 的两步),确认任一步失败时整体失败;
  6. 写一份"门禁验证记录"(放在项目 README 或注释里):门禁的命令、阈值、以及你用什么方式验证过它真的会失败。

验收方式:能展示第 2 步的失败输出与第 6 步的记录。

扩展任务:给"只降不升"策略做一个极简实现——把每次通过的覆盖率写入 coverage-baseline.json,下次运行时比较,低于基线就失败(提示:需要处理"首次运行"的情况)。

11. 练习题 ​

基础题

  1. 判断:覆盖率门禁配好后就一定能拦住覆盖率下降。(对 / 错)
  2. 填空:验证门禁真的生效的方法是把阈值 ______,观察它是否 ______;门禁应放在 ______ 层执行。
  3. 简答:为什么"只对新增代码设覆盖率要求"比"整体阈值"更适合老项目?

代码阅读题

python
def main() -> int:
    if failures:
        print("门禁未通过")
        return 1
    print("门禁通过")
    return 0
  1. 返回值 0 和 1 在 CI 里分别意味着什么?
  2. 如果脚本抛了未捕获的异常(如文件不存在),退出码会是什么?CI 会怎样?这是好事还是坏事?

编程题

  1. 扩展门禁脚本支持"只降不升"策略:
    • 增加 --baseline 参数,指向一个 JSON 文件(如 coverage-baseline.json);
    • 如果没有基线文件:只打印当前数字,写入基线,退出码 0(首次运行);
    • 如果有基线:实际值低于基线则失败(即使高于绝对阈值);
    • 通过时更新基线文件(只升不降);
    • 用真实的两次运行验证:① 首次生成基线;② 删一个测试后运行应当失败。

综合任务

  1. 为一个项目设计完整的"质量门禁方案":
    • 包含四类门禁:测试通过、覆盖率、静态检查(可选)、构建产物(可选);
    • 说明每类门禁的触发时机、阈值、失败后的处理流程;
    • 设计"门禁豁免"机制:什么情况下允许跳过?需要谁批准?如何记录? (提示:真实项目里总有"紧急修复"的场景,需要一个可控的例外通道)
    • 写出 CI 配置的完整片段;
    • 额外要求:列出"这套门禁要怎么验证它真的生效"(对每一类门禁都给出验证方法)。
分级提示

提示 1(方向):第 4 题的关键是"CI 通过退出码判断成败"。

提示 2(关键点):第 6 题的"首次运行"要特别处理——没有基线时应该"记录并放行",否则第一次就会失败。

提示 3(接近答案):第 7 题的"豁免机制"通常用 CI 变量或提交信息里的标记(如 [skip-coverage]),并且必须有记录——豁免的本质是"让例外可见",而不是"让例外消失"。

12. 参考答案 ​

基础题
  1. 错。本节的核心发现就是"配了但没生效":JaCoCo 插件级的 <rules> 在配置不完整时会静默通过(输出 "All coverage checks have been met.")。任何门禁都必须经过"故意制造失败"的验证。
  2. 临时调高到不可能达到的值(或删掉一个关键测试);失败(构建变红);CI。
  3. 因为老项目的存量代码覆盖率天然低,设整体阈值只有两种结果:① 一开始就失败,团队被迫降低标准;② 把阈值定得很低,等于没有约束。

"只对新增代码设要求"绕开了这个问题:它不管历史欠账,只保证"这次改动带来了测试"。长期执行下去,整体覆盖率会自然上升——而且提升是来自"新代码有测试",而不是"老代码刷数字"。

代码阅读题(第 4、5 题)

第 4 题:

退出码CI 的判定开发者的感受
0步骤成功,继续执行绿
1步骤失败,流水线中断红,PR 无法合并

这就是"脚本式门禁"的全部机制——它不比插件高级,只是把判断逻辑写在了明面上。

第 5 题:未捕获异常时,Python 以退出码 1 结束(非零)。

CI 会判定失败 ✓

这是好事:"脚本自己出问题"和"覆盖率不达标"都表现为失败——宁可误报,也不要静默通过。

对比插件的行为:插件配置错误时可能什么都不做还是报成功——所以脚本式门禁在"失败可见性"上更安全。

顺带一个实践建议:如果希望区分"门禁不通过"和"脚本异常",可以用不同的退出码(如 1 = 不达标、2 = 脚本错误),CI 上就能看出区别。

编程题(第 6 题)
python
"""覆盖率门禁(含"只降不升"策略)。

用法:
    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())

验证两步:

powershell
# ① 首次运行:生成基线
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 之间的微小差异会导致误报。

综合任务(第 7 题)

质量门禁方案:

markdown
## 一、门禁清单

| # | 门禁 | 触发 | 阈值 | 失败处理 |
|---|------|------|------|---------|
| 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% 门禁有用得多。

13. 掌握标准 ​

  • [ ] 能说出门禁的三个层次与各自的执行时机;
  • [ ] 能说出三种阈值策略,并知道"只降不升"最适合老项目;
  • [ ] 知道门禁会静默失效,并且会做"故意失败"的验证;
  • [ ] 能写出并运行脚本式门禁(含参数化阈值);
  • [ ] 能把门禁接入 CI 并归档报告;
  • [ ] 设计门禁时会一并设计"豁免机制"与"验证方法"。

14. 扩展资源 ​

15. 下一步学习 ​

TA 单元测试专项的主体内容到此完成(剩余两节依赖 Spring 章节):

接下来可以进入 S3 企业开发基础:MySQL、Spring、Git 与 Linux(该阶段页面将在产出时建立)。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引