Civitai 测试评审方法论:用变异法验证测试能否真正变红 — 基于 civitai-test-review Agent 规范
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
本文基于 Civitai 仓库中的测试评审 Agent 规范 .claude/agents/civitai-test-review.md,系统讲解该仓库如何回答测试评审的唯一核心问题:“如果被测代码坏了,这些测试真的会失败吗?”。读完后,你将掌握一套“审查回退而非审查运行”(review the revert, not the run)的评审方法,并了解 Civitai 主应用(src/)中真实踩过的九类测试陷阱——从不终止的 fake、断言 mock 形状、mock 掉行为所在层、零收集测试套件、vitest 项目选择错误、浏览器测试中的自删状态竞态——以及 36 个以测试形式运行的“约定守卫”(convention guards)如何把仓库规范固化为可执行的检查。
一、定位与分工:只回答一个问题
civitai-test-review是仓库.claude/agents/目录下的一组评审 Agent 之一,其 frontmatter 声明了如下元信息:
- 适用范围:
src/(主 Next.js 应用)及其所导入的 packages 中的测试。apps/下的 SvelteKit 应用属于另一套svelte-*-review三件套(svelte-correctness-review、svelte-idiom-review、svelte-abstraction-review),不归它管。 - 工具集:
Read, Grep, Glob, Bash—— 只读探索加定向命令执行,不做修改。 - 配套评审:与
civitai-reuse-review(复用)、civitai-correctness-review(正确性)、civitai-perf-review(性能)、civitai-intent-review(意图)并列,评审一个功能分段时并行运行、各自守好自己的一亩三分地("Stay in your lane")。
规范开头强制一条前置动作:开始之前完整阅读根目录 CLAUDE.md 的 Testing 章节。该章节是“从真实事故中写出的教条,大部分只记录在这一处”(doctrine written off real incidents, most of it recorded nowhere else),构成本评审的实质依据。
评审不回答“有没有覆盖率”——覆盖率太容易满足,且经常只是形式上满足。它只回答一个问题:代码回归时,测试是否会以可读的方式失败?复用、安全性、性能、请求保真度各有其评审者,越界是禁忌。
二、核心方法:审查回退,而非审查运行
A green suite proves current behaviour. It says nothing about how the test fails.(绿色套件只能证明当前行为,对测试“如何失败”毫无说明。)
回归是否可读(legible)是一个独立属性,它决定测试是否真的在保护任何东西。规范给出的操作手法是对 diff 中每个测试显式做一次变异评审:
- 为被测代码指定一个合理的变异(mutation,即一个具体的错误改动);
- 陈述该测试会打印出什么。只有三种结果:
- 出现一条消息有用的具名断言失败 → 测试是真实的;
- 15 秒超时 → 偏弱,且可能是竞态(见后文浏览器测试一节);
- 什么都不发生——测试仍然通过,或者挂起→ 测试本身就是要报告的发现。
如果你说不出一个能让测试变红的变异,那么这个测试无论包含多少条断言,什么都没测。要在报告里直说,并说明你尝试过哪个变异。
三、陷阱一:挂起而非失败的 fake
这是文档用 🔴 标记的最高危陷阱,并且“在这个仓库真实发生过”:
通过“不终止”来证明一个属性,不是证明——测试运行器观察不到它。
一个驱动循环却永不终止的 fake,会把一次回归变成对“已 resolve 的 promise”无限await的循环。这是纯微任务(microtask)循环:它饿死宏任务队列,而 vitest 的testTimeout基于setTimeout(宏任务),因此永远不会触发。仓库实测数据:一个 300 ms 的setTimeout始终未执行,fake 在 4 秒内跑了4,194,305 次迭代。CI 表现为任务挂起直至被 kill——没有断言失败、没有超时、没有任何可阅读的输出。
规范给出的规则是:
- 任何驱动有界循环的 fake 必须自行终止,并且测试必须断言循环提前停止。例如把一个游标(cursor)fake 的上限设在 50 页,一次本不可报告的挂起就会在不到 1 秒内变成一条
expected 51 to be less than 5的断言失败。 - 仓库内的参照实现:src/server/auth/tests/session-invalidation.test.ts 中
tokenCount = 10_000的上限,以及其旁边的会终止的分页 fake。该测试源码注释解释了上限的由来:被测的 map 构建曾写成 O(n²) 的reduce浅拷贝,quadratic 形态会同步阻塞循环,vitest 的超时在其跑完之前无法触发,所以“回退必须在断言上失败,而不是把 runner 卡死”。
守卫层面,src/server/services/tests/no-unbounded-paging-fake.test.ts 只保护游标形状(cursor-shaped)的 fake——它通过 ESLintRuleTester驱动根目录 eslint-local-rules.js 中的no-unbounded-paging-fake本地规则,valid 样例要求上限返回终态游标(cursor: '0'/0/null/''),invalid 样例正是当年上线的“每页返回String(pages)永不终止”的形态。文档明确提醒:游标之外的循环驱动形式仍然要靠人工发现,这个守卫只是缩小该类问题而非关闭它。
四、陷阱二:空洞断言与过度宽松的共享 fixture
文档列出六类在该仓库“都真实上线过”的空洞测试形态:
- 断言的是mock 返回的值,而非代码计算出的任何东西;
expect(result).toBeDefined()/not.toThrow()/ 一条裸快照作为唯一断言;- 共享 fixture 宽到修复版和坏掉的代码都能通过——经典形态是字段满足所有分支的 fixture,导致实际上没有任何分支被选中;
- 测试断言的是逻辑的副本而不是逻辑本身:测试自己重新实现了计算再比较,两者同生共死,谁都没被检查;
- 对
Prisma.sql片段的断言:片段顺序与插值行为和“读模板时的直觉”不同,一条“看起来对”的断言可能在校验一段被打乱的字符串; - 形状像变异测试却没有负向对照(negative control)——没有任何东西证明“仅 setup 本身”就已经产生了被断言的状态。
五、陷阱三:断言 mock 的形状,而非断言被测物的形状
文档指出一种更难看见的空洞形态:“测试可以观察到完全真实的东西,却断言了一个无法区分失败的性质。” 给出的实战案例:
一个检查原始 SQL 语句的测试,把断言对象构造为
executeRaw.mock.calls.map(([strings]) => strings.join('?'))——即把 tagged template连同占位符一起重新拼接,然后断言结果包含SET LOCAL lock_timeout。而该语句正是因为Postgres 的SET没有参数形式才无效的:让代码坏掉的那一个东西($1占位符),恰是测试重新拼回去的东西。测试在坏掉的形态上通过,而该功能整条声称路径每次调用都抛错。
由此提炼的评审问题:被断言的工件到底是什么?重新拼接的模板、mock 的.calls形状、参数个数——这些描述的是测试脚手架(harness);被发射出的语句、持久化的行、计算出的值——这些描述的才是代码。断言后者。
文档附带一条 ⚠️ 补充:收紧一条宽松断言时,要检查新断言是追加还是替换了旧断言。上例的修复版同时断言了字面量和$1的缺席,却悄悄丢掉了原来的SET LOCAL lock_timeout检查——于是SET lock_timeout(缺LOCAL,会把超时泄漏给同一个池化后端后续借用的每条语句)能穿过收紧后的测试。
六、陷阱四:mock 掉了行为真正发生的层
一个vi.mock了行为所在模块的套件,无论断言写得多好,都无法观察到该行为——它只是关于调用方的证据,却被读作整条路径的证据。
文档给出一个跨越合并(merge)的真实案例:两个并行 PR 各自 hook 了同一个事件,一个在共享服务内部,另一个在该服务上方的调用方——而调用方那个套件整体 mock 掉了共享服务。两个套件在各自的分支里都站得住脚;但合并后那次最自然的“整理”(把两个 hook 合并、忘了删掉一个)会让事件在每次触发时双发,而一条测试都不会变红,因为唯一能看见它的套件把那一层 mock 掉了。
操作规则:
- 对每个行为,说出它发生在哪一层,然后检查断言它的套件是否把那一层 mock 了;
- 存在共享收口点(chokepoint)时,证明互斥性的测试应放在运行真实收口点的套件里,而不是任何一个调用方的套件里;
- 形如
toHaveBeenCalledWith(...)的断言,旁边没有toHaveBeenCalledTimes(1)时,连它自己能看见的那一层上的双发调用都抓不到。
七、陷阱五:过宽的 mock —— 优先 importOriginal
优先importOriginal,而不是手列vi.mock导出的写法。手列导出的vi.mock把测试耦合到被测物整个传递导入图上,而这个图变宽时没有任何警告:加一个 service import 可能把在加载期构建pLimit/prom collector 的模块(例如~/server/search-index→meilisearch/client)拖进来,套件随后在一个远离改动点的错误处加载失败;pnpm typecheck和pnpm lint保持绿色,只有 CI 能抓到。
规范给出的标准写法(同样出现在 CLAUDE.md 的 Testing 章节):
vi.mock('~/server/prom/client', async (importOriginal) => ({ ...(await importOriginal<typeof PromClient>()), dbReadFallbackCounter: { inc: vi.fn() }, }));注意配套细节:用顶层import type * as PromClient——内联的typeof import('...')会触发 ESLint 的consistent-type-imports规则。
更进一步的判断:在接受一个变宽的 mock 之前,先问这条导入边是否根本不该存在。套件失败常常意味着代码拉进了它不想要的依赖,加宽 mock 只是把问题藏起来。文档说明这一点“咬过我们不止一次”,其中一次的正确修复是把 helper 提取到独立模块。该约定由no-wholesale-module-mock.test.ts与no-direct-shared-module-mock.test.ts两个守卫兜底(见第十二节)。
八、陷阱六:收集到零条测试的套件
A suite that collects nothing does not report red — it reports nothing.(收集不到任何东西的套件不报红——它什么都不报。)
收集为空的运行,以退出码或汇总来看,读起来仍然像通过。仓库见过的成因包括:导入期抛错的模块、缺失的子模块、破坏收集的 mock 迁移、文件名与项目的 glob 不匹配。
仓库中一个具体量级的例子:缺少event-engine-common子模块的新 worktree 会让 src/server/routers/tests/blocks.router.workflow.test.ts收集失败、贡献 0 条测试(CLAUDE.md 记录同一文件在正常基线上收集 308 条测试)。因此操作规则是:如果 diff 新增或移动了测试文件,确认该文件收集到非零数量,并且说出这个数字。
九、陷阱七:项目选择 ——--project 'unit*',绝不能用--project unit
这是文档再次用 🔴 标记的操作性规则,可在 vitest.config.mts 中得到完整印证:
- 主应用的 unit 套件是两个vitest 项目:
unit与unit-native。unit的include为['src/**/*.test.ts', 'scripts/**/*.test.ts'],并把六个真正执行 sharp 的文件(sharpExecutingTestFiles)排除(vitest.config.mts);unit-native恰好认领这六个文件,pool: 'forks',原因是 sharp 0.32.6 的原生插件不是 context-aware,线程池在线程拆毁时可能 SIGSEGV(vitest.config.mts);
- 这六个文件是被exclude出
unit的,而不是被路由到别处——所以对它们显式指定--project unit <文件>会得到No test files found。
后果:--project unit会静默运行 1065 个文件中的 1059 个并退出 0——少掉的正是 6 个unit-native文件。“选择器只匹配两个项目之一,就是对一个你没运行的套件做出的绿色运行。” 如果 diff 触碰了 vitest 配置、项目 glob 或测试脚本,必须专项检查这一点。
package.json 中的脚本正是按此原则编写:test:packages:run用--project '@civitai/*'选中 packages 套件,test:apps:run用--project 'app:*'选中 apps 套件(apps 各自显式设置test.name为app:<name>,与 packages 的@civitai/*命名保持结构性互斥)。
十、陷阱八:放错位置的测试 —— 永远不要把单元测试放进src/pages
Next.js 把src/pages下的每一个.ts/.tsx文件——包括嵌套的__tests__/——都当作路由,并对它做路由类型校验:测试文件会以类似Type '...test' does not satisfy the constraint 'ApiRouteConfig'. Property 'default' is missing的报错让next build失败。
关键是检测窗口:只有next build能抓到它——pnpm typecheck、vitest、CI 的 typecheck/unit/component 任务全部通过,问题一路潜伏到 preview 的构建步骤。正确做法:handler 测试放在src/pages之外的__tests__/目录(例如src/server/__tests__/),通过~/pages/...别名导入 handler。文档注明这一条“曾在 PR #2653 咬过我们”。
十一、陷阱九:浏览器/组件测试中的“自删状态”竞态
Never
awaita state that deletes itself.(永远不要 await 一个会自我删除的状态。)
机制:expect.element轮询(首次立即、之后每50 ms一次)消耗的是测试剩余预算;browser 模式testTimeout为 15 s,且component项目不覆写它。等待状态到来是安全的——加载只会让它更晚到,matcher 会持续轮询;等待一个将要离开的状态(到达上限时的 spinner、防抖窗口、任何由定时器拆除的东西)则是 matcher 必输的竞态:状态一旦消失,剩下每一次轮询也都太晚了。表现是“安静的机器上绿,繁忙的机器上红,没有任何 PR 可归咎”。
两个可接受的修复,按优先级:
- 把状态做成吸收态(absorbing)——驱动组件使其无法被夺走(例如
rerender一个大到计时器不可能触发的窗口),然后再断言,并加一个负向对照证明“仅 prop 变化”本身并未产生该状态; - 根本不断言瞬态——await 吸收态的终局,用非 DOM 可观察量(如 mock 调用计数)钉住中间步骤确实发生过。
🔴扩大 matcher 预算、加retry、或放大组件自身超时都不是修复——它们把快速失败变成慢速失败,并且恰好在 CI 最慢的时候让竞态依旧不可赢。diff 中出现三者之一必须被标记。
⚠️ 诊断提示:一条 ~15 s 的失败测试是候选过滤条件,不是诊断结论。非竞态变异同样在 ~15 s 失败;而两条健康通过的测试合法地花 15 s 等一个真实的产品超时(CLAUDE.md 记录:四个非竞态变异在 14.97–15.09 s 失败,两条通过测试合法地跑了 15.06 s / 15.26 s)。区分“自删状态”与“从未到达”的方法是:在动作后同步地读可观察量(present-then-gone vs never-present),或临时放大组件自身窗口看失败是否消失(仅诊断用途——把放大后的窗口上线正是本规则禁止的)。两种修复的完整实例在 src/components/Apps/AppsSubmitEditView.browser.test.tsx 的两条 retry 测试中;上述每个数字背后的测量记录见 claudedocs/rca-appblocks-component-suite-flake-2026-08-05.md。
十二、约定守卫:以测试形式运行的 36 条仓库规范
Civitai 把大量“仓库约定”实现为测试而非 ESLint 规则:36 个守卫位于 src/server/services/tests/ 的no-*.test.ts文件中。文档逐一列出其名称与守护意图(括号内为文档给出的守护理由):
| 守卫 | 守护什么 |
|---|---|
no-agent-ground-truth-write | 防止 agent 直写 ground truth 数据 |
no-coerce-boolean-in-api | API 层不得强制布尔转换 |
no-direct-shared-module-mock | 共享 mock 棘轮(ratchet),防止绕过共享 mock 约定 |
no-divergent-can-generate-derivation | 覆盖度不等于 canGenerate——ecosystem 还必须支持该模型类型,二者只在isGenerationEligible中组合 |
no-divergent-model-recency-derivation | New/Updated 卡片规则与其“一天前”截断各只有一个定义——曾有 3 张卡片各自复述该规则;当付费徽标占走 ModelCard 唯一的 status 槽位后只剩那份拷贝知道,导致刚发布几分钟的付费模型在 feed 显示 “Paid”、在资源选择器显示 “New” |
no-divergent-paid-gate-derivation | feed 与搜索索引必须从同一个 helper 推导付费徽标,绝不允许两份查询拷贝 |
no-divergent-safetensor-rule | 覆盖度视图与checkLoadable两次陈述 checkpoint SafeTensor 规则且没有代码执行这段 SQL,故两个字面量与 checkpoint 作用域只能被文本性地钉住 |
no-doubled-free-slot-noun | 防止自由槽位名词重复 |
no-hand-typed-redis-key-constants | Redis key 常量棘轮——allowlisted mock 里手敲的REDIS_KEYS曾漂移 15 次 |
no-io-in-transaction | 事务内不得做 IO |
no-job-kind-on-remix-mint | remix 溯源铸造必须签kind: 'mint'——jobtoken 在上传路径可消费,即“免费的 remix 画廊提交” |
no-lint-rules-script-drift | 防止守卫清单与test:lint-rules脚本漂移(见下文) |
no-menu-target-tooltip-nesting | Tooltip嵌在Menu.Target内部会抢走菜单所需的 ref,触发器静默地不再打开 |
no-module-scope-cache | 禁止模块作用域缓存 |
no-pk-addressed-engagement-write | 禁止以主键直址的互动写操作 |
no-server-infra-in-app-graph | 服务器基础设施不得进入应用图 |
no-sharp-outside-native-project | sharp 只允许在unit-native项目执行 |
no-ssr-divergent-media-query | 防止 SSR 分叉的媒体查询 |
no-stale-moderator-route-probe | 防止过期的 moderator 路由探测 |
no-static-html2canvas-import | 禁止静态导入 html2canvas |
no-unbounded-paging-fake | 分页 fake 必须有界(见第三节) |
no-unbumped-draft-status-write | 把 Model 移入Draft的原始 SQL 写必须同步updatedAt = now(),否则remove-old-drafts可无宽限期级联删除 |
no-unguarded-billable-submit | user-token 的 orchestrator submit 必须校验 owner(参见assertWorkflowOwner) |
no-unguarded-block-rest-token | 每个 block REST 页面路由必须由withBlockScope包裹——它是 REST 表面做 approved-status 决策的唯一地点;路由里裸写的verifyBlockToken就是当年 bridge 犯过的同一种形状 |
no-unguarded-block-bridge-token | 每个 tRPC bridge proc 必须经authorizeBlockBridgeToken解析 claims,绝不允许裸verifyBlockToken |
no-unguarded-user-text | 用户文本必须有防护 |
no-unloadable-image-fixture | 禁止不可加载的图片 fixture |
no-unmoderated-blob-retraction | 允许要求 image-cache 服务销毁图片共享存储对象(跨账户、不可逆)的流程台账——仅限 moderation |
no-unmuteable-comment-processor | 评论处理器必须可被解除静音 |
no-unscoped-email-verification-exemption | 邮箱验证豁免不得无作用域 |
no-untruthy-query-gate | 被 feature flag 门控的查询必须强制转换 flag——稀疏 flag 读出来是undefined,而 React Query 会把它当作“已启用” |
no-unverified-provenance-write | 禁止未验证的溯源写入 |
no-unroled-image-resource-match | 资源检测不得仅凭哈希值把图片匹配到模型——否则打包进来的上游组件文件会给陌生人的 checkpoint 记功 |
no-unpriced-default-model | 默认模型必须定价 |
no-unwrapped-knob-rotation | knob 轮换不得缺少包裹 |
no-wholesale-module-mock | 即第七节的importOriginal规则 |
目录实际清点确认当前恰好是这 36 个文件。
test:lint-rules:手工维护的文件清单,不是 glob
package.json 中test:lint-rules脚本显式列出 41 个文件:36 个no-*守卫加上notification-settings-polarity.test.ts、track.addView.schema.test.ts、hub-filter-parity.test.ts、poi-checks-strip-benign-phrases.test.ts、video-leaderboard-badge-staging.test.ts这 5 个非no-*文件。
⚠️ 因为它是手工维护的文件列表,一个新守卫可能不在其中,于是只在完整套件运行时才失败。文档记录:最近一次审计(2026-08-24)曾一次缺 5 个,当时已补入。因此规则是:diff 新增守卫时,检查它已被接线进脚本;不要把一次绿色的test:lint-rules读作“所有守卫都通过了”。而“36”与“41”这两个数字、以及列表本身,由 src/server/services/tests/no-lint-rules-script-drift.test.ts 看守——它逐字读取两处措辞(`<n> live in `src/server/services/__tests__/no-*.test.ts`与`test:lint-rules` names <n> files today,并核对反引号中的名称列表与目录、脚本的一致性),所以更新时“改数字,不改句形”。CLAUDE.md 同时声明.claude/agents/civitai-test-review.md中的对应段落也在覆盖范围内——这正是本规范中保留这两句数字表述的原因。
守卫失败时,修代码。新增豁免必须在 diff 中陈述理由。
十三、其他值得看一眼的弱信号
规范列出的“Also worth a look”清单,每条都指向一种静默失效:
- 断言实现细节的测试——将来必须和它一起改写的细节,脆弱却不提供保护;
- diff 中遗留的
skip/only/todo——一个only会静默丢掉整个文件的其余测试; - 未被 await 的异步断言——测试在期望执行前就结束了;
- 依赖真实
Date.now()或时间顺序、今天恰好通过的测试; - 缺失的失败路径覆盖——风险通常就住在那里,只有现在时态(present-tense-only)的断言会漏掉它。
十四、如何运行:定向文件、读日志而非摘要
规范对“运行”本身划了明确的边界:
- 可以运行定向文件——它们便宜且不排队:
pnpm run test:unit:run src/server/services/__tests__/<file>.test.ts- 不要为好奇心启动完整
pnpm run test:unit:run:它经由 dev-server 队列(scripts/test-unit-run.mjs)串行化,很慢,会阻塞其他人的运行; - ⚠️如果一次运行返回得可疑地快或输出为空,先确认守护进程还活着再相信结果——运行中途的 daemon 死亡会产生一条无保护的
fetch failed,读起来是“无输出的退出码 0”。读日志,不要读摘要。 - 不要用
pnpm test(Playwright)或 component 套件充当检查手段。
这与 vitest.config.mts 的注释互相印证:test:unit:run是入队的,而test:component、test:packages:run、test:apps:run、test:lint-rules直接调用 vitest;且入队运行看不到调用方环境(daemon 用自己的环境 spawn),所以要限量 worker 用 CLI flag--max-workers=8(会被转发)而不是环境变量VITEST_MAX_WORKERS(入队运行不继承)。
十五、报告格式与交付义务
报告的每条发现需要包含:file:line、该测试声称覆盖什么、你尝试的变异及其打印结果、以及应改成什么。排序标准是该测试制造了多少虚假信心:守护资金或鉴权路径的空洞测试,优先级高于一个格式化函数上的脆弱断言。
必须区分两类结论,因为它们需要不同的工作:
- “这条测试不工作”(this test does not work);
- “这个行为没被测”(this behaviour is untested)。
只报发现。不要清点你读过且确认健康的测试;如果该分段的测试确实扎实,直说——这是一个真实的结果。唯一例外是一行字:你发现较弱但刻意如此的测试,附理由。
最后是规范中最强硬的一段——交付义务:
Your findings reach nobody unless you deliver them.(不交付,你的发现就谁也到不了。)
写在自己 transcript 里的文字不会发送到任何地方;分析完成不等于工作完成。报告必须以最终消息文本返回;若以 subagent 身份运行且自身文本到不了调用者,必须显式发送。绝不静默收尾(never go idle without reporting)。
理由是一条信息学事实:汇总各评审通道的人无法区分“一条沉默的通道”和“一条没发现东西的通道”——从外部看两者完全相同。于是汇总后的评审读起来完整无缺,却整条缺了你的通道;你做的功不仅丢失了,还被计为“无发现”的证据。一条沉默的通道比一条失败的通道更糟:失败是可见的。文档说明这在一次真实运行中发生过,而消失的那条通道恰好持有那一轮最锋利的发现。这一段的推理是规则,而非措辞:在任何“发现会止步于你自己”的情境中(包括这段没有预见到的情境),都必须交付。
结语:一条从事故中长出的测试工程规范
回顾这篇规范的结构,会发现它不是抽象的“最佳实践清单”,而是一份事故驱动的工程契约:每一个陷阱条目都对应仓库里一次真实上线的回归(PR #2653 的src/pages测试、#3756 的无界分页 fake、#3645 的组件套件竞态),每条规则都配有实测数字(4,194,305 次迭代、1059/1065、14.97–15.09 s、308 条测试的收集验证)和一个可执行的落点——36 个no-*.test.ts守卫把规范本身变成测试,no-lint-rules-script-drift再把“规范与脚本一致”这件事也变成测试。对任何在大型 monorepo 中运行 AI 辅助评审或多 Agent 并行的团队,这套“变异评审 + 守卫测试 + 强制交付”的方法论都具备直接的可借鉴价值。
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考