问题与结论
开发者完成一个功能后,通常会运行一组测试。
控制台显示全部通过,Unity 没有报错,示例场景也能运行。于是工作记录里出现了一句话:
测试通过。
这句话看起来没有问题,却省略了几个关键条件:
- 测试的是哪一份代码?
- 使用的是不是干净工作区?
- 运行在哪个操作系统和 Unity 版本?
- 是否包含未提交文件、本机缓存或本地配置?
- 测试覆盖的是纯 C# 模型、Unity Editor,还是最终 Player?
- 结果是否由独立 Runner 重新执行?
- CI 验证的提交是否就是准备发布的提交?
- 测试结果和日志是否还能被复查?
只要这些问题没有答案,“测试通过”就不是完整事实,而是一条缺少边界的结论。
先说结论:测试通过不是布尔值,而是一条带上下文的证据声明
测试证据等级(即“可信台阶”):根据被验证代码的身份、执行环境、覆盖范围、独立性和可复现程度,对测试结论划分的可信层级。
local-passed 和 runner-passed 都可以是真实结果。
问题不在于本地测试没有价值,而在于二者证明的事情不同。
local-passed证明:在某个开发者当前拥有的本地环境中,一组操作曾经成功。runner-passed证明:自动化执行环境按照预定流程重新取得代码并完成了对应验证。same-SHA runner-passed进一步证明:Runner 验证的正是被登记或准备交付的那一份提交。- Release Evidence 还要证明:被发布的产物来源、内容和此前通过验证的代码能够对应起来。
因此,更准确的表达不是:
这个功能通过了测试。
而是:
这份代码在什么环境中、以什么范围、通过了哪一级验证。
证据与分析
一条测试证据需要自己的“身份证”
证据身份(即“证据身份证”):能够唯一说明一次验证测试了什么、在哪里测试、如何测试以及得到什么结果的最小信息集合。
一条可审计的证据至少应包含:
| 信息 | 回答的问题 |
|---|---|
| Source Commit | 测试的是哪一份代码 |
| Workspace State | 是否包含未提交或未跟踪改动 |
| Environment | 操作系统、运行时、Unity 和工具版本是什么 |
| Command / Workflow | 通过什么入口执行 |
| Scope | 覆盖了哪些包、程序集、场景或平台 |
| Result | 成功、失败、超时还是未执行 |
| Artifact | 是否保留日志、XML、Player 或报告 |
| Timestamp | 结果产生于什么时候 |
| Producer | 由开发者、本地脚本还是远端 Runner 生成 |
| Evidence Class | 该结果允许支持哪一级结论 |
缺少证据身份证时,同一句“通过”可能指向完全不同的事实:
开发者修改了五个文件
→ 只提交了其中三个
→ 本地测试使用完整工作区通过
→ CI 只检出提交中的三个文件
→ CI 失败
本地结果和 CI 结果并不矛盾。
它们测试的根本不是同一份代码。
local-passed 究竟证明了什么
本地验证(即“手边能跑”):在开发者当前机器和工作区中完成的构建、测试或运行检查。
手边能跑非常重要。
它适合:
- 快速发现编译错误;
- 验证算法和数据模型;
- 调试生命周期;
- 运行需要交互或特殊设备的检查;
- 在提交前缩短反馈周期;
- 验证尚未整理成 CI 的实验性能力。
但它天然包含一组不完全受控的变量:
- 未提交文件;
- 未跟踪配置;
- 本机安装的软件;
- 用户目录中的缓存;
- 已存在的 Unity Library;
- 本地环境变量;
- 手动安装的 SDK;
- 曾经执行过的初始化步骤;
- 开发者本人对操作流程的隐性知识。
因此,local-passed 通常只能支持:
当前开发工作区在这台机器上通过了指定验证。
它不能自动支持:
- 干净检出可以通过;
- 其他开发者可以复现;
- CI Runner 可以通过;
- Windows、Linux 或 macOS 都可以通过;
- Unity Player 可以通过;
- 发布候选已经通过;
- 正式产物与本地测试对象一致。
Runner 的价值不是“换一台电脑再跑一次”
独立 Runner 验证(即“异地复验”):由自动化执行环境从明确代码身份出发,按照版本化流程重新构建和测试,并保留可追溯结果。
异地复验的主要价值不是机器位于远端,而是它切断了部分本地隐式依赖。
典型流程是:
固定代码与工具版本 → 准备可识别环境 → 执行版本化命令 → 解析实际结果 → 保存带身份的 Artifact
它可以暴露本地环境长期掩盖的问题:
- 文件没有提交;
- 路径大小写不一致;
- 脚本依赖本地 PATH;
- 测试依赖旧缓存;
- 生成文件没有同步;
- Windows 和 Unix Shell 行为不同;
- Unity 包依赖没有声明;
- 构建脚本需要人工输入;
- 测试在干净项目中无法启动;
- 测试完成后进程无法退出。
但 Runner 也不是自动等于完整交付证明。
它仍然需要回答:
- Runner 测试的是不是目标提交?
- Workflow 是否覆盖目标能力?
- 测试是否真的执行,而不是被条件跳过?
- 结果文件是否生成?
- Job 是否因为超时或取消而没有结论?
- 构建产物是否就是后续发布产物?
为什么同一个提交 SHA 如此重要
同提交验证(即“同一份代码复验”):测试结果、候选版本和发布流程明确绑定到同一个不可变提交身份。
考虑下面的流程:
提交 A
→ CI 通过
继续开发
→ 产生提交 B
使用当前 main 构建候选版本
→ 实际内容来自 B
发布说明写:
→ CI 已通过
这里的“CI 已通过”只对提交 A 成立。
候选版本来自提交 B,因此提交 A 的结果不能直接为 B 背书。
正确关系应该是:
确定候选提交 B
→ 对 B 执行所需测试
→ 证据绑定 B
→ 从 B 组装候选产物
→ 验证产物仍对应 B
→ 才能进入后续发布判断
同一份代码复验防止的是证据和交付对象之间发生身份漂移。SHA 相同仍不保证实际输入相同:未提交文件、外部依赖、生成内容、工具链与测试选择也必须按被验证合同记录;不能用同 SHA 掩盖这些变化。
“CI 全绿”也可能只证明了很小的范围
证据覆盖范围(即“证明范围”):一次测试结果实际覆盖的包、平台、场景、配置和行为边界。
假设一个大型 Unity 框架修改了 UI、Networking 和 ECS,但 Player Smoke 始终只构建 core-only Profile。
即使 Player Smoke 通过,它也只能证明:
Core Only 闭包可以构建并启动。
它不能证明:
- UI 可以进入 Player;
- Networking 的平台条件编译正确;
- ECS Runtime 的依赖完整;
- 新增的 Unity Adapter 没有 Editor-only API;
- 完整消费闭包可以启动。
问题不在于 Core Only Smoke 没有价值。
问题在于不能把窄范围结果描述成全框架验证。
更可靠的证据记录应明确写出:
| 证据 | 能证明 | 不能证明 |
|---|---|---|
| 纯 C# 单元测试 | 核心模型和算法行为 | Unity 生命周期与序列化 |
| Unity EditMode | Editor 环境中的程序集和部分 Unity API | Player 构建与运行 |
| Unity PlayMode | 场景和运行时生命周期 | 所有目标平台 |
| Core Only Player Smoke | 最小稳定闭包可构建启动 | 受影响 Preview 包 |
| 受影响闭包 Player Build | 指定包可以进入目标 Player | 其他平台和未覆盖场景 |
| Runner Matrix | 声明平台和命令可复现 | 正式发布产物身份 |
| Candidate Qualification | 候选提交和候选产物通过门禁 | 用户环境中的所有组合 |
证明范围越窄,结果反馈越快。
快速验证不是问题;模糊地扩大结论才是问题。
“没有结果”不等于失败
工程状态里必须区分至少四种结论:
| 状态 | 含义 |
|---|---|
| Passed | 验证已执行并满足判定条件 |
| Failed | 验证已执行并明确不满足条件 |
| Pending | 验证需要执行,但尚未取得结果 |
| Not Requested | 当前阶段没有要求执行该验证 |
此外还可能存在:
- Skipped:流程执行,但该检查因条件不匹配而跳过;
- Cancelled:执行被外部取消;
- Timed Out:执行超时,无法得到正常完成结果;
- Inconclusive:有输出,但证据不足以作出通过或失败判断;
- Stale:结果曾经有效,但已不对应当前代码或配置。
这一区分非常重要。
没有 Workflow Run,不能写成 runner-failed。
本地测试通过,也不能把 runner-pending 改成 runner-passed。
当前没有进入候选发布阶段,则 Release Evidence 可以是 not-requested,而不是制造一条虚假的发布阻塞。
证据不是一条简单直线
可信台阶经常被画成:
local
→ runner
→ candidate
→ release
这个方向有帮助,但并不完整。
证据实际包含多个相互独立的维度:
| 维度 | 示例 |
|---|---|
| 代码身份 | 工作区、Commit、Tag、Candidate |
| 执行环境 | 本机、容器、自托管 Runner、托管 Runner、真机 |
| 覆盖范围 | 单元测试、包闭包、Sample、Player、平台矩阵 |
| 独立性 | 开发者手动执行、脚本执行、CI 自动执行 |
| 产物身份 | 临时构建、保存 Artifact、候选 Bundle、正式 Release |
| 可追溯性 | 无日志、本地日志、测试报告、固定 Run、签名清单 |
| 生命周期 | 开发验证、合并门禁、候选资格化、发布验证 |
因此,证据更像一个矩阵,而不是单个数字。
一个测试可以:
- 在 Runner 上执行;
- 绑定正确提交;
- 但只覆盖纯 C# 单元测试。
另一个测试可以:
- 在本机执行真实 Android Player;
- 覆盖目标设备;
- 但没有可复现脚本和固定产物。
二者各自有价值,不能简单地只比较谁“级别更高”。
能力正确性、重构安全和发布资格不是同一类证据
大型仓库容易把所有未完成验证都放进一个 Evidence Backlog。
结果是:
- 一个小 UI 控件测试;
- 一次稳定 API 快照;
- 一次跨平台 Player Build;
- 一次候选版本完整资格化
全部被当成同类债务。
更合理的分类是:
能力正确性证据
回答:
新增或修改的功能本身是否符合预期?
例如:
- 选择组约束测试;
- 状态机转换测试;
- 安装器失败恢复;
- 关键 PlayMode 生命周期;
- 网络协议编解码。
重构安全证据
回答:
后续继续修改时,哪些边界受到自动保护?
例如:
- Stable API Snapshot;
- 反向依赖闭包;
- 架构边界验证;
- 重复类型审计;
- Installer 幂等性测试。
发布资格证据
回答:
某个不可变候选是否满足交付要求?
例如:
- same-SHA 平台矩阵;
- exact-tree 产物组装;
- 候选包完整性;
- 签名与哈希;
- 严格 Release Readiness。
当前处于能力开发阶段时,应优先补能力正确性和重构安全证据。
没有明确候选提交时,不应机械启动完整发布资格化,也不应因此声称项目发布失败。
Evidence Backlog 为什么会失控
如果每个小计划完成后都永久产生一条独立 Runner 债务,证据列表会迅速膨胀。
例如:
Timer 控件
Choice Group
State View
Hold Progress
Typed Item Select
MVVM Sample
Quickstart
Newcomer Entry
如果每项都维护一套长期独立验证,团队最终管理的是计划历史,而不是产品能力。
更合理的组织方式是围绕消费边界建立资格化套件:
UI Runtime Qualification
→ UI 模型
→ Unity Adapter
→ Typed UI Sample
→ Player 闭包
Editor Consumption Qualification
→ Installer
→ Guided Recipe
→ Sample Promotion
→ Reload Recovery
Runtime Foundation Qualification
→ Core
→ Event
→ Pooling
→ Bootstrap Lite
小切片首先进入对应能力套件。
套件在适当时机统一执行远端或平台验证。
这样,Evidence Backlog 表达的是哪些产品能力尚未获得资格,而不是仓库曾经完成过多少微型计划。
状态记录不应该靠自由文本猜测
工作记录中常见这样的描述:
实现完成,尚未提交、推送和触发 Runner。
几天后代码已经进入默认分支,但这段文本没有更新。
机器生成状态继续复制它,于是出现:
Git 事实:文件已经进入默认分支
状态文本:仍然声称尚未提交
解决方式不是要求维护者永远记得修改一句话,而是将关键事实结构化。
例如:
implementationCommit
mergedToDefaultBranch
testedCommit
localEvidence
runnerEvidence
runnerEvidenceRun
candidateCommit
releaseArtifact
其中:
- Git 负责证明代码是否进入某个提交;
- Runner 负责证明某个提交是否执行远端验证;
- Artifact 负责证明结果是否保留;
- Candidate 负责确定准备交付的不可变身份;
- 自由文本只负责解释下一步和异常情况。
可机器判断的事实,不应长期藏在自然语言里。
适用边界与实践
证据升级必须由新事实触发
证据晋级(即“升级盖章”):只有当更高层级要求的执行和身份条件真实满足后,才能提升状态。
正确的晋级流程类似:
列出目标结论所需证据 → 核对已有证据的输入与范围 → 补齐缺失执行或身份核验 → 按实际结果更新状态
证据复用也可以成立:相关源码、依赖、配置、测试选择和环境未变时,不必仅因交接或角色切换重跑。跨提交复用需要目标项目允许的等价性证明及清楚的来源记录,不能把“旧结果仍适用”改写成“新 SHA 已在 Runner 执行”。
不能使用以下替代逻辑:
开发者说本地通过
→ 自动写 runner-passed
旧提交的 CI 通过
→ 自动写当前 main 已通过
某个最小 Profile 通过
→ 自动写整个框架 Player 通过
候选之前的构建通过
→ 自动写正式产物已验证
每次升级都需要满足目标结论的事实依据;可以是新增执行,也可以是原本已存在但刚完成身份与范围核验的有效证据,不能只换成更乐观的措辞。
三个常见误判
误判一:本地全部通过,所以 CI 失败一定是 Runner 问题
也可能是:
- 未提交文件;
- 本机缓存;
- 路径大小写;
- 本地环境变量;
- 隐式安装的工具;
- 不可重复的手工步骤。
CI 失败首先说明环境之间存在差异,不应先假定远端错误。
误判二:CI 全绿,所以准备发布
CI 可能只运行:
- 静态检查;
- 单元测试;
- Core Only;
- 单一操作系统;
- 非候选提交。
是否可以发布,还取决于候选身份、目标平台、产物和发布门禁。
误判三:没有远端结果,所以功能没有完成
功能实现和远端交付证明是两个状态。
更准确的记录可以是:
implementation: completed
localEvidence: passed
runnerEvidence: pending
releaseEvidence: not-requested
这既不夸大,也不抹杀已经完成的工作。
我的证据检查清单
在把某项能力登记为“已验证”之前,我会检查:
- 结果绑定的是工作区、Commit、Tag 还是 Candidate?
- 工作区是否包含未提交或未跟踪内容?
- 测试命令是否已进入版本控制?
- 执行环境是否可识别和复现?
- 测试覆盖的是模型、Editor、PlayMode 还是 Player?
- 覆盖的是最小闭包还是受影响包闭包?
- 目标操作系统和 Unity 版本是什么?
- 测试是否真正执行,还是被条件跳过?
- 测试结果是否保留了 XML、日志或构建产物?
- Runner 验证的提交是否等于状态记录中的提交?
- 候选版本是否就是同一个已验证提交?
- 发布产物是否能追溯到该候选?
- 当前状态应是 Passed、Failed、Pending 还是 Not Requested?
- 证据是否已经因为新提交或配置变化而过期?
- 这条证据具体能证明什么,又明确不能证明什么?
- 是否把微型计划证据错误地当成完整产品资格?
- 是否存在只有自然语言描述、没有结构化身份的状态?
- 更高等级的结论是否真的获得了新的执行事实?
只要其中一个关键问题没有答案,“测试通过”就仍然需要补充限定条件。
工程可信度不来自测试数量,而来自团队能够明确回答:
哪一份代码,在什么环境中,以什么范围,获得了什么级别的证明?
来源与验证状态
本文是证据判定方法,不新增任何测试通过声明。可结合Quickstart 的实际失败与完成边界阅读,并核对候选身份是否绑定同一份输入;本轮仅修复缺失流程、输入身份与证据复用的表述,未运行 Runner 或发布流程。