Civitai 测试评审方法论:用变异法验证测试能否真正变红 — 基于 civitai-test-review Agent 规范
2026/9/16 17:32:28 网站建设 项目流程

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-reviewsvelte-idiom-reviewsvelte-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 中每个测试显式做一次变异评审

  1. 为被测代码指定一个合理的变异(mutation,即一个具体的错误改动);
  2. 陈述该测试会打印出什么。只有三种结果:
    • 出现一条消息有用的具名断言失败 → 测试是真实的;
    • 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-indexmeilisearch/client)拖进来,套件随后在一个远离改动点的错误处加载失败;pnpm typecheckpnpm 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.tsno-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 项目:unitunit-native
    • unitinclude['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);
  • 这六个文件是被excludeunit的,而不是被路由到别处——所以对它们显式指定--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.nameapp:<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 咬过我们”。

十一、陷阱九:浏览器/组件测试中的“自删状态”竞态

Neverawaita state that deletes itself.(永远不要 await 一个会自我删除的状态。)

机制:expect.element轮询(首次立即、之后每50 ms一次)消耗的是测试剩余预算;browser 模式testTimeout为 15 s,且component项目不覆写它。等待状态到来是安全的——加载只会让它更晚到,matcher 会持续轮询;等待一个将要离开的状态(到达上限时的 spinner、防抖窗口、任何由定时器拆除的东西)则是 matcher 必输的竞态:状态一旦消失,剩下每一次轮询也都太晚了。表现是“安静的机器上绿,繁忙的机器上红,没有任何 PR 可归咎”。

两个可接受的修复,按优先级:

  1. 把状态做成吸收态(absorbing)——驱动组件使其无法被夺走(例如rerender一个大到计时器不可能触发的窗口),然后再断言,并加一个负向对照证明“仅 prop 变化”本身并未产生该状态;
  2. 根本不断言瞬态——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-apiAPI 层不得强制布尔转换
no-direct-shared-module-mock共享 mock 棘轮(ratchet),防止绕过共享 mock 约定
no-divergent-can-generate-derivation覆盖度不等于 canGenerate——ecosystem 还必须支持该模型类型,二者只在isGenerationEligible中组合
no-divergent-model-recency-derivationNew/Updated 卡片规则与其“一天前”截断各只有一个定义——曾有 3 张卡片各自复述该规则;当付费徽标占走 ModelCard 唯一的 status 槽位后只剩那份拷贝知道,导致刚发布几分钟的付费模型在 feed 显示 “Paid”、在资源选择器显示 “New”
no-divergent-paid-gate-derivationfeed 与搜索索引必须从同一个 helper 推导付费徽标,绝不允许两份查询拷贝
no-divergent-safetensor-rule覆盖度视图与checkLoadable两次陈述 checkpoint SafeTensor 规则且没有代码执行这段 SQL,故两个字面量与 checkpoint 作用域只能被文本性地钉住
no-doubled-free-slot-noun防止自由槽位名词重复
no-hand-typed-redis-key-constantsRedis key 常量棘轮——allowlisted mock 里手敲的REDIS_KEYS曾漂移 15 次
no-io-in-transaction事务内不得做 IO
no-job-kind-on-remix-mintremix 溯源铸造必须签kind: 'mint'——jobtoken 在上传路径可消费,即“免费的 remix 画廊提交”
no-lint-rules-script-drift防止守卫清单与test:lint-rules脚本漂移(见下文)
no-menu-target-tooltip-nestingTooltip嵌在Menu.Target内部会抢走菜单所需的 ref,触发器静默地不再打开
no-module-scope-cache禁止模块作用域缓存
no-pk-addressed-engagement-write禁止以主键直址的互动写操作
no-server-infra-in-app-graph服务器基础设施不得进入应用图
no-sharp-outside-native-projectsharp 只允许在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-submituser-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-rotationknob 轮换不得缺少包裹
no-wholesale-module-mock即第七节的importOriginal规则

目录实际清点确认当前恰好是这 36 个文件。

test:lint-rules:手工维护的文件清单,不是 glob

package.json 中test:lint-rules脚本显式列出 41 个文件:36 个no-*守卫加上notification-settings-polarity.test.tstrack.addView.schema.test.tshub-filter-parity.test.tspoi-checks-strip-benign-phrases.test.tsvideo-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:componenttest:packages:runtest:apps:runtest: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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询