系列目录

问题与结论

开发者完成一个功能后,通常会运行一组测试。

控制台显示全部通过,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

这既不夸大,也不抹杀已经完成的工作。

我的证据检查清单

在把某项能力登记为“已验证”之前,我会检查:

  1. 结果绑定的是工作区、Commit、Tag 还是 Candidate?
  2. 工作区是否包含未提交或未跟踪内容?
  3. 测试命令是否已进入版本控制?
  4. 执行环境是否可识别和复现?
  5. 测试覆盖的是模型、Editor、PlayMode 还是 Player?
  6. 覆盖的是最小闭包还是受影响包闭包?
  7. 目标操作系统和 Unity 版本是什么?
  8. 测试是否真正执行,还是被条件跳过?
  9. 测试结果是否保留了 XML、日志或构建产物?
  10. Runner 验证的提交是否等于状态记录中的提交?
  11. 候选版本是否就是同一个已验证提交?
  12. 发布产物是否能追溯到该候选?
  13. 当前状态应是 Passed、Failed、Pending 还是 Not Requested?
  14. 证据是否已经因为新提交或配置变化而过期?
  15. 这条证据具体能证明什么,又明确不能证明什么?
  16. 是否把微型计划证据错误地当成完整产品资格?
  17. 是否存在只有自然语言描述、没有结构化身份的状态?
  18. 更高等级的结论是否真的获得了新的执行事实?

只要其中一个关键问题没有答案,“测试通过”就仍然需要补充限定条件。

工程可信度不来自测试数量,而来自团队能够明确回答:

哪一份代码,在什么环境中,以什么范围,获得了什么级别的证明?

来源与验证状态

本文是证据判定方法,不新增任何测试通过声明。可结合Quickstart 的实际失败与完成边界阅读,并核对候选身份是否绑定同一份输入;本轮仅修复缺失流程、输入身份与证据复用的表述,未运行 Runner 或发布流程。