web-testing-visual-regression

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Visual Regression Testing

视觉回归测试

Quick Guide: A visual test asserts that a rendered subject still looks the way a human approved it. The assertion is trivial; everything hard is around it — making the render deterministic, scoping the subject tightly, generating baselines in the same environment that will diff them, and reviewing every diff before accepting a new baseline. A baseline accepted without looking at it turns the suite into a machine that asserts the bug.

<critical_requirements>
快速指南: 视觉测试用于验证渲染后的对象外观与人工确认的一致。断言本身很简单,难点在于确保渲染的确定性、精准界定测试对象、在相同环境中生成与对比基线,以及在接受新基线前审查每一处差异。未经审查就接受基线会让测试套件变成一个验证错误的工具。

<critical_requirements>

CRITICAL: Before Using This Skill

重要提示:使用此技能前须知

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type
, named constants)
(You MUST look at the diff before accepting any baseline — an unreviewed baseline permanently encodes whatever was on screen, including the regression)
(You MUST make the render deterministic before capturing — fonts loaded, animations stopped, time frozen, data fixed — or the diff reports noise instead of regressions)
(You MUST generate and compare baselines in the same environment — same pinned container image, same browser build — never commit baselines rendered on a developer machine)
(You MUST scope the capture to the subject under test — an element or a clip — and reserve full-page captures for cases where the page itself is the subject)
(You MUST mask dynamic regions rather than loosening the comparison threshold — a threshold wide enough to absorb a live timestamp is wide enough to absorb a broken layout)
</critical_requirements>

Auto-detection: visual regression, visual testing, screenshot testing, snapshot image, baseline image, toHaveScreenshot, pixel diff, maxDiffPixels, maxDiffPixelRatio, diffThreshold, Chromatic, TurboSnap, accept baseline, update snapshots, golden image, UI diff
When to use:
  • Catching unintended visual changes in components, pages, or design-system primitives
  • Protecting a shared component library where one CSS change fans out across consumers
  • Verifying theme/viewport matrices (light and dark, mobile and desktop) render as designed
  • Guarding states that are hard to assert semantically — spacing, overflow, z-order, focus rings, truncation
  • Locking down a marketing or print-style page where the layout is the requirement
When NOT to use:
  • Asserting text content, ARIA structure, or behaviour — a text or role assertion fails with a readable message; a pixel diff makes a human squint at two images
  • Guarding a surface that legitimately changes every render (live feeds, ad slots, animated canvases) unless those regions are masked
  • Substituting for missing functional coverage — a screenshot proves the pixels moved, never that the feature works
Explicitly out of scope (owned by sibling skills):
  • Writing stories, story args/decorators, and workshop setup —
    web-tooling-storybook
  • Functional end-to-end flows, locator strategy, and network interception mechanics —
    web-testing-playwright-e2e
  • Unit and component-level assertions —
    web-testing-vitest
    ,
    web-testing-react-testing-library
Key patterns covered:
  • Harness choice: self-hosted image comparison vs cloud change-detection service
  • Configuration-driven comparison — tolerances and the baseline matrix live in config, not in assertions
  • Determinism checklist — fonts, motion, time, randomness, data, scrollbars, environment
  • Story-driven coverage — a story catalog as the visual corpus, modes, interaction states
  • Baseline lifecycle — proposing, reviewing, and accepting a new approved image
  • CI wiring — when the visual job runs, what artifacts it emits, how cost stays bounded
Detailed Resources:
  • examples/core.md - Config defaults, project matrix, masking, subject scoping
  • examples/determinism.md - Fonts, motion, frozen clocks, seeded randomness, scrollbars, container parity
  • examples/story-driven.md - Story corpus, modes, interaction states, change detection
  • examples/ci.md - Visual job wiring, diff artifacts, baseline-update workflow, cost control
  • reference.md - Option tables, harness comparison matrix, determinism checklist, CLI flags

<philosophy>
所有代码必须遵循CLAUDE.md中的项目约定(短横线命名法、命名导出、导入顺序、
import type
、命名常量)
(接受任何基线前必须查看差异 —— 未经审查的基线会永久固化当前屏幕内容,包括回归错误)
(捕获截图前必须确保渲染的确定性 —— 字体加载完成、动画停止、时间冻结、数据固定 —— 否则差异报告会显示噪声而非回归错误)
(必须在相同环境中生成和对比基线 —— 使用相同的固定容器镜像、相同的浏览器版本 —— 绝不要提交在开发者机器上渲染的基线)
(必须将捕获范围限定在测试对象上 —— 单个元素或裁剪区域 —— 仅当页面本身为测试对象时才进行全页捕获)
(必须屏蔽动态区域而非放宽对比阈值 —— 宽到足以容纳实时时间戳的阈值,也足以掩盖布局崩溃问题)
</critical_requirements>

自动检测关键词: visual regression、visual testing、screenshot testing、snapshot image、baseline image、toHaveScreenshot、pixel diff、maxDiffPixels、maxDiffPixelRatio、diffThreshold、Chromatic、TurboSnap、accept baseline、update snapshots、golden image、UI diff
适用场景:
  • 捕获组件、页面或设计系统基础元素中的非预期视觉变更
  • 保护共享组件库,避免一处CSS变更影响多个消费者
  • 验证主题/视口矩阵(亮色/暗色、移动端/桌面端)是否按设计渲染
  • 保护难以通过语义断言的状态 —— 间距、溢出、层级、焦点环、截断效果
  • 锁定营销页面或印刷风格页面,其中布局本身就是需求
不适用场景:
  • 断言文本内容、ARIA结构或行为 —— 文本或角色断言会返回可读的失败信息;像素差异需要人工对比两张图片
  • 保护每次渲染都会合法变化的界面(实时信息流、广告位、动画画布),除非这些区域被屏蔽
  • 替代缺失的功能测试覆盖 —— 截图只能证明像素变化,无法证明功能正常
明确不属于本技能范围(由关联技能负责):
  • 编写故事、故事参数/装饰器以及工作台设置 ——
    web-tooling-storybook
  • 端到端功能流程、定位策略和网络拦截机制 ——
    web-testing-playwright-e2e
  • 单元和组件级断言 ——
    web-testing-vitest
    web-testing-react-testing-library
涵盖的核心模式:
  • 工具选择:自托管图像对比 vs 云端变更检测服务
  • 配置驱动的对比 —— 容差和基线矩阵存储在配置中,而非断言内
  • 确定性检查清单 —— 字体、动画、时间、随机性、数据、滚动条、环境
  • 故事驱动的覆盖 —— 将故事目录作为视觉测试 corpus、模式、交互状态
  • 基线生命周期 —— 提议、审查和接受新的已批准图像
  • CI集成配置 —— 视觉测试任务的运行时机、输出的工件、成本控制方式
详细资源:
  • examples/core.md - 配置默认值、项目矩阵、屏蔽设置、测试对象范围界定
  • examples/determinism.md - 字体、动画、冻结时钟、种子随机数、滚动条、容器一致性
  • examples/story-driven.md - 故事corpus、模式、交互状态、变更检测
  • examples/ci.md - 视觉测试任务集成、差异工件、基线更新流程、成本控制
  • reference.md - 选项表、工具对比矩阵、确定性检查清单、CLI标志

<philosophy>

Philosophy

理念

Every other kind of test states its intent in code:
expect(total).toBe(42)
says what correct means. A visual test states its intent in a file — an approved image somebody looked at once. That single difference drives everything.
Three consequences follow:
  1. The baseline is a review artifact, not a build artifact. It is the recorded judgement of a human who decided the UI was right. Regenerating it without looking destroys the only thing the test knows.
  2. Nondeterminism is not flake, it is a false requirement. A pixel that varies between runs is being asserted as if it were part of the design. Either pin it or mask it — never widen the tolerance until it stops complaining.
  3. The diff is the output. A visual suite that fails without producing a viewable expected/actual/diff triplet is unactionable, and unactionable suites get disabled within two sprints.
What visual tests are good at: whole-appearance properties that no reasonable assertion expresses — a shadow that vanished, a 3px shift that broke alignment, a font that failed to load, a dark-theme token that resolved to white-on-white, a container that stopped clipping overflow.
What they are bad at: anything you could name. If you can write
toBeVisible()
,
toHaveText()
, or an accessibility-tree assertion, write that instead. Those fail with a sentence; a pixel diff fails with homework.
The economic model matters more than the API. Self-hosted comparison is free per run and expensive per human hour — baselines live in the repository, review happens in code review, and every environment mismatch is your problem. Cloud change detection inverts that: pay per snapshot, get parallel capture, hosted review UI, and a shared, environment-controlled renderer. Choose deliberately; retrofitting is a migration, not a flag.
</philosophy>
<decision_framework>
其他类型的测试通过代码声明意图:
expect(total).toBe(42)
明确了正确的标准。而视觉测试通过文件声明意图 —— 一张经过人工审核的已批准图像。这一差异决定了所有后续操作。
由此产生三个结论:
  1. 基线是审查工件,而非构建工件。 它记录了人工判断UI正确的结果。未经查看就重新生成基线会破坏测试的唯一价值。
  2. 非确定性不是偶发故障,而是错误的需求。 每次运行都变化的像素会被当作设计的一部分进行断言。要么固定它,要么屏蔽它 —— 绝不要放宽容差直到不再报错。
  3. 差异是输出结果。 如果视觉测试套件失败但未生成可查看的预期/实际/差异三联图,那么它是无法操作的,这类套件会在两个迭代内被禁用。
视觉测试擅长的场景: 无法通过合理断言表达的整体外观属性 —— 消失的阴影、破坏对齐的3px偏移、加载失败的字体、解析为白对白的暗色主题令牌、停止裁剪溢出的容器。
视觉测试不擅长的场景: 任何可以命名的内容。如果你能编写
toBeVisible()
toHaveText()
或可访问性树断言,就优先使用这些方式。它们会返回明确的失败句子;而像素差异则需要人工处理。
经济模型比API更重要。 自托管对比每次运行免费,但人工成本高 —— 基线存储在仓库中,审查在代码评审中进行,所有环境不匹配问题都需自行解决。云端变更检测则相反:按快照付费,获得并行捕获、托管审查UI和受控的共享渲染环境。请谨慎选择;事后改造是迁移,而非简单切换标志。
</philosophy>
<decision_framework>

Choosing a Harness

选择测试工具

Two families exist. They differ in where the baseline lives, who accepts a change, and who owns the render environment.
Do you already maintain a browsing test suite and a container image for CI?
├─ NO  → Do you have a story catalog covering component states?
│        ├─ YES → Cloud change-detection service (story-driven)
│        └─ NO  → Build the state coverage first; a harness cannot invent subjects
└─ YES → Is baseline review expected to happen in code review?
         ├─ YES → Self-hosted image comparison (baselines committed to the repo)
         └─ NO  → Is a hosted review UI with per-change accept/deny required
                  by designers or non-engineers?
                  ├─ YES → Cloud change-detection service
                  └─ NO  → Self-hosted image comparison
DimensionSelf-hosted comparison (
toHaveScreenshot
)
Cloud change detection (Chromatic)
Baseline custodyPNG/WebP files committed to the repo, versioned with the codeStored per branch in the service; merge resolves to the base branch
Who accepts a changeA code reviewer approving the PR containing the new imagesA named human clicking accept in the review UI; recorded per change
Review surfaceWhatever your report renders + the image diff in the PRHosted side-by-side/onion-skin diff, comment threads, designer access
Render environmentYours — you pin the image and eat every mismatchTheirs — one controlled renderer, no dev-vs-CI drift
Cost shapeFree per snapshot; CI minutes + engineer hours on environment workBilled per snapshot; near-zero environment maintenance
ParallelismBounded by your CI workers and shardsFanned out cloud-side; wall-clock roughly independent of corpus size
Repo impactImages inflate clone size; every matrix cell multiplies filesNo images in the repo
Matrix costOne baseline file per project × subject; you maintain all of themOne snapshot per mode × subject; billed, but not maintained by you
Best fitApp-level pages, teams already running browser tests, no budgetDesign systems, large state catalogs, cross-functional review
Hybrid is legitimate and common: cloud change detection over the component/state catalog (where breadth and designer review matter) and a handful of self-hosted full-page checks for critical app routes. What is not legitimate is two harnesses covering the same subjects — you get two baselines, two review flows, and two chances to accept the wrong one.
</decision_framework>

<patterns>
存在两类工具,它们的差异在于基线存储位置、变更接受者以及渲染环境所有者。
你是否已维护浏览测试套件和CI用的容器镜像?
├─ 否 → 你是否有覆盖组件状态的故事目录?
│        ├─ 是 → 云端变更检测服务(故事驱动)
│        └─ 否 → 先构建状态覆盖;工具无法凭空生成测试对象
└─ 是 → 是否期望在代码评审中进行基线审查?
         ├─ 是 → 自托管图像对比(基线提交到仓库)
         └─ 否 → 设计师或非工程师是否需要带变更接受/拒绝功能的托管审查UI?
                  ├─ 是 → 云端变更检测服务
                  └─ 否 → 自托管图像对比
维度自托管图像对比(
toHaveScreenshot
云端变更检测(Chromatic)
基线管理PNG/WebP文件提交到仓库,与代码一起版本化按分支存储在服务中;合并时解析为基础分支的基线
变更接受者代码评审者批准包含新图像的PR指定人员在审查UI中点击接受;记录每个变更的操作
审查界面报告渲染内容 + PR中的图像差异托管的并排/洋葱皮差异视图、评论线程、设计师访问权限
渲染环境自行维护 —— 需固定镜像并处理所有不匹配问题服务方维护 —— 单一受控渲染器,无开发环境与CI环境差异
成本模式每快照免费;CI时长 + 环境维护的工程师工时按快照计费;环境维护成本几乎为零
并行能力受限于CI worker和分片数云端并行处理;耗时基本与corpus大小无关
仓库影响图像会增加克隆大小;每个矩阵单元都会新增文件仓库中无图像文件
矩阵成本每个项目×测试对象对应一个基线文件;需自行维护所有文件每个模式×测试对象对应一个快照;需付费但无需自行维护
最佳适配应用级页面、已运行浏览器测试的团队、无预算限制设计系统、大型状态目录、跨职能评审
混合方案合理且常见: 对组件/状态目录使用云端变更检测(广度和设计师评审很重要),对关键应用路由使用少量自托管全页检查。不合理的是用两个工具覆盖相同测试对象 —— 会产生两个基线、两个审查流程,两次接受错误结果的机会。
</decision_framework>

<patterns>

Core Patterns

核心模式

Pattern 1: Configuration-Driven Comparison

模式1:配置驱动的对比

Tolerances, path layout, and the baseline matrix belong in configuration. Per-assertion options are for what genuinely varies by subject — the mask list, the clip box.
容差、路径布局和基线矩阵应放在配置中。每个断言的选项仅用于真正因测试对象而异的内容 —— 屏蔽列表、裁剪框。

Defaults live in config

默认值存储在配置中

typescript
// playwright.config.ts
const PIXEL_THRESHOLD = 0.2; // per-pixel YIQ distance, 0-1; library default
const MAX_DIFF_PIXEL_RATIO = 0.01; // share of the image allowed to differ

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      threshold: PIXEL_THRESHOLD,
      maxDiffPixelRatio: MAX_DIFF_PIXEL_RATIO,
      stylePath: "./visual/stabilize.css",
    },
  },
});
Why good: one place to tune sensitivity, no drifting per-file magic numbers,
stylePath
applies stabilizing CSS to every capture without touching a test
Why the two knobs are not interchangeable:
threshold
decides whether a single pixel counts as different;
maxDiffPixels
/
maxDiffPixelRatio
decide how many differing pixels are tolerated. Anti-aliasing needs the first; nothing needs the second set above ~1% except a subject you should have masked.
typescript
// playwright.config.ts
const PIXEL_THRESHOLD = 0.2; // 单像素YIQ距离,0-1;库默认值
const MAX_DIFF_PIXEL_RATIO = 0.01; // 允许差异的图像占比

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      threshold: PIXEL_THRESHOLD,
      maxDiffPixelRatio: MAX_DIFF_PIXEL_RATIO,
      stylePath: "./visual/stabilize.css",
    },
  },
});
优势: 集中调整敏感度,避免每个文件的魔法值漂移,
stylePath
无需修改测试即可为所有捕获应用稳定CSS
两个参数不可互换的原因:
threshold
决定单个像素是否算作差异;
maxDiffPixels
/
maxDiffPixelRatio
决定允许多少差异像素。抗锯齿需要前者;除了应屏蔽的测试对象外,后者设置超过1%毫无意义。

The matrix comes from projects, not duplicated tests

矩阵来自项目配置,而非重复测试

typescript
// One test, four baselines - never four copy-pasted tests
projects: [
  {
    name: "desktop-light",
    use: { ...devices["Desktop Chrome"], colorScheme: "light" },
  },
  {
    name: "desktop-dark",
    use: { ...devices["Desktop Chrome"], colorScheme: "dark" },
  },
  {
    name: "mobile-light",
    use: { viewport: MOBILE_VIEWPORT, colorScheme: "light" },
  },
  { name: "firefox", use: devices["Desktop Firefox"], ignoreSnapshots: true },
];
Why good: the project name enters the baseline filename, so each cell gets its own approved image;
ignoreSnapshots: true
keeps a browser in the functional run without a second baseline set to maintain
Why bad (the alternative): duplicating a test per theme means the day the subject changes you update N tests, and the day one is forgotten it silently stops covering anything.
typescript
// 一个测试,四个基线 —— 绝不要复制粘贴四个测试
projects: [
  {
    name: "desktop-light",
    use: { ...devices["Desktop Chrome"], colorScheme: "light" },
  },
  {
    name: "desktop-dark",
    use: { ...devices["Desktop Chrome"], colorScheme: "dark" },
  },
  {
    name: "mobile-light",
    use: { viewport: MOBILE_VIEWPORT, colorScheme: "light" },
  },
  { name: "firefox", use: devices["Desktop Firefox"], ignoreSnapshots: true },
];
优势: 项目名称会加入基线文件名,每个矩阵单元都有自己的已批准图像;
ignoreSnapshots: true
可在功能测试中保留浏览器,无需维护第二套基线
劣势(替代方案): 为每个主题复制测试意味着当测试对象变更时,你需要更新N个测试,一旦遗漏一个,它就会悄悄停止覆盖。

Mask what moves; do not widen the threshold

屏蔽动态区域;不要放宽阈值

typescript
await expect(page).toHaveScreenshot("dashboard.png", {
  mask: [
    page.getByTestId("last-updated"),
    page.getByTestId("live-visitor-count"),
  ],
  maskColor: "#000000", // per-call only - not accepted in the config-level expect block
});
Why good: the volatile region is replaced by a flat block, so the rest of the frame stays under strict comparison
Why bad: raising
maxDiffPixelRatio
until the timestamp stops failing also buys enough slack to hide a collapsed column.
See examples/core.md for the full config, the mobile/dark matrix, WebP baselines, and
pathTemplate
layout.

typescript
await expect(page).toHaveScreenshot("dashboard.png", {
  mask: [
    page.getByTestId("last-updated"),
    page.getByTestId("live-visitor-count"),
  ],
  maskColor: "#000000", // 仅每个调用可设置 —— 配置级expect块不接受此参数
});
优势: 易变区域被替换为纯色块,帧的其余部分保持严格对比
劣势: 提高
maxDiffPixelRatio
直到时间戳不再报错,也会产生足够的空间来隐藏折叠的列。
查看examples/core.md获取完整配置、移动端/暗色矩阵、WebP基线和
pathTemplate
布局。

Pattern 2: Determinism Before Assertion

模式2:断言前确保确定性

Six sources of nondeterminism account for nearly every flaky visual test. Each has a fix that is cheaper than the flake.
SourceSymptom in the diffFix
Web fontsWhole blocks of text shift; fallback metricsSelf-host and preload, then await
document.fonts.ready
Animation/transitionRandom intermediate framesAssertion default disables CSS animation; kill JS motion too
Time"2 minutes ago", clocks, date-stamped rowsFreeze the clock to a fixed instant before navigating
RandomnessShuffled lists, random ids, placeholder avatarsSeed the generator via an init script
DataRow order and counts vary per runServe fixed fixtures with stable ordering and fixed identifiers
Scrollbars / DPR15px width shift, blurry text at 2×Hide scrollbars in stabilizing CSS; pin
scale
and viewport
typescript
const FIXED_NOW = new Date("2026-01-15T12:00:00Z");

await page.clock.setFixedTime(FIXED_NOW); // freeze - do not fast-forward, that resumes motion
await page.goto(DASHBOARD_URL);
await page.evaluate(() => document.fonts.ready); // resolves only once every face is usable
await expect(page.getByRole("region", { name: /summary/i })).toHaveScreenshot(
  "summary.png",
);
Why good: the frame is a pure function of the code under test — rerunning it a hundred times produces one image
Why bad (the alternative):
waitForTimeout(1000)
before the capture is a bet that the slowest machine in the fleet finishes in under a second, and it is a bet you lose on the day a reviewer is watching.
The seventh source is the machine itself. Font rasterization and subpixel rendering differ between macOS, Windows, and Linux, and between container images. Baselines generated on a laptop and diffed against CI produce a permanent full-frame difference on every subject — which trains the team to regenerate blindly, which destroys the suite. Generate baselines inside the same pinned image CI uses.
See examples/determinism.md for the stabilizing stylesheet, seeded-random init script, font preloading, and the containerized baseline-generation command.

六大非确定性来源导致了几乎所有不稳定的视觉测试。每个来源都有比解决偶发故障更廉价的修复方案。
来源差异中的症状修复方案
Web字体整段文本偏移;回退字体度量自托管并预加载,然后等待
document.fonts.ready
动画/过渡随机中间帧断言默认禁用CSS动画;同时禁用JS动画
时间"2分钟前"、时钟、带日期戳的行导航前将时钟冻结在固定时刻
随机性随机排序的列表、随机ID、占位头像通过初始化脚本为生成器设置种子
数据行顺序和数量每次运行都不同提供固定的测试数据,确保顺序和标识符稳定
滚动条 / 设备像素比15px宽度偏移、2×分辨率下模糊文本在稳定CSS中隐藏滚动条;固定
scale
和视口
typescript
const FIXED_NOW = new Date("2026-01-15T12:00:00Z");

await page.clock.setFixedTime(FIXED_NOW); // 冻结 —— 不要快进,快进会恢复动画
await page.goto(DASHBOARD_URL);
await page.evaluate(() => document.fonts.ready); // 仅当所有字体可用时才会解析
await expect(page.getByRole("region", { name: /summary/i })).toHaveScreenshot(
  "summary.png",
);
优势: 帧是测试代码的纯函数 —— 运行一百次只会生成一张图像
劣势(替代方案): 捕获前
waitForTimeout(1000)
是在赌集群中最慢的机器能在一秒内完成,而当评审者关注时,你一定会输。
第七个来源是机器本身。 macOS、Windows和Linux之间的字体光栅化和子像素渲染存在差异,容器镜像之间也有差异。在笔记本电脑上生成的基线与CI环境对比时,每个测试对象都会显示全帧差异 —— 这会让团队盲目重新生成基线,最终破坏测试套件。必须在CI使用的固定镜像内生成基线。
查看examples/determinism.md获取稳定样式表、种子随机初始化脚本、字体预加载和容器化基线生成命令。

Pattern 3: Story-Driven Coverage

模式3:故事驱动的覆盖

A story catalog is a ready-made visual corpus: every meaningful state already has an addressable, isolated, prop-controlled render. Visual coverage then becomes a question of state enumeration rather than test authoring.
故事目录是现成的视觉测试corpus:每个有意义的状态都有可访问、隔离、由属性控制的渲染。视觉覆盖就变成了状态枚举的问题,而非测试编写。

One story per meaningful state

每个有意义的状态对应一个故事

The unit of visual coverage is a state, not a component.
Button
needs one subject;
Button
in loading, disabled, destructive, icon-only, and long-label states needs five. States that differ only by a prop the eye cannot see do not need their own subject.
视觉覆盖的单元是状态,而非组件。
Button
需要一个测试对象;加载中、禁用、危险状态、仅图标、长标签的
Button
需要五个测试对象。仅属性不同但视觉无差异的状态不需要单独的测试对象。

Interaction-produced states are capturable

交互产生的状态可被捕获

A cloud change-detection service waits for the whole interaction script attached to a story to finish before capturing, so open menus, expanded rows, and post-submit validation states become subjects without hand-written fixtures. Interaction failures fail the build rather than producing a snapshot.
云端变更检测服务会等待附加到故事的整个交互脚本完成后再捕获,因此打开的菜单、展开的行和提交后的验证状态无需手动编写测试数据即可成为测试对象。交互失败会导致构建失败,而非生成快照。

Modes multiply subjects deliberately

模式有意地增加测试对象数量

typescript
export const allModes = {
  "light desktop": { theme: "light", viewport: "large" },
  "dark mobile": { theme: "dark", viewport: "small" },
} as const;

// Applied at project, component, or story level - levels stack, they do not override
parameters: { chromatic: { modes: { "dark mobile": allModes["dark mobile"] } } }
Why good: the theme/viewport matrix is declared once and reused, and each mode keeps an independent baseline
Why bad: applying every mode at project level multiplies the entire corpus — snapshots are billed per mode per subject, and a 400-story catalog at four modes is 1,600 snapshots per build.
Change detection, not pixel policing: the service groups differing snapshots into a reviewable change set per build, tracks accept/deny per snapshot, and carries approvals forward so an accepted change stops reappearing on later builds of the same branch.
See examples/story-driven.md for state enumeration, mode definitions, per-subject tolerance parameters, and opting subjects out.

typescript
export const allModes = {
  "light desktop": { theme: "light", viewport: "large" },
  "dark mobile": { theme: "dark", viewport: "small" },
} as const;

// 可在项目、组件或故事级别应用 —— 级别叠加,而非覆盖
parameters: { chromatic: { modes: { "dark mobile": allModes["dark mobile"] } } }
优势: 主题/视口矩阵只需声明一次即可复用,每个模式都有独立的基线
劣势: 在项目级别应用所有模式会使整个corpus数量倍增 —— 快照按模式×测试对象计费,400个故事×4个模式每次构建会产生1600个快照。
变更检测,而非像素监管: 服务会将差异快照分组为每个构建的可审查变更集,跟踪每个快照的接受/拒绝状态,并保留批准记录,使已接受的变更不会在同一分支的后续构建中再次出现。
查看examples/story-driven.md获取状态枚举、模式定义、每个测试对象的容差参数和排除测试对象的方法。

Pattern 4: Baseline Lifecycle

模式4:基线生命周期

The lifecycle has exactly three legitimate events: create (a new subject appears), accept (an intended change is reviewed and approved), and retire (the subject is deleted, and its images go with it). Anything else is drift.
生命周期只有三个合法事件:创建(新测试对象出现)、接受(预期变更经过审查并批准)、淘汰(测试对象被删除,其图像也随之删除)。其他任何情况都是漂移。

Never regenerate blind

绝不要盲目重新生成

bash
undefined
bash
undefined

WRONG - "the visual tests are failing" reflex

错误 —— 凭"视觉测试失败"的直觉操作

npx playwright test --update-snapshots
npx playwright test --update-snapshots

RIGHT - look first

正确 —— 先查看

npx playwright test --project=desktop-light # emits expected/actual/diff per failure npx playwright show-report # inspect every diff npx playwright test --update-snapshots=changed # only after each one is understood

**Why bad:** a wholesale regenerate rewrites every baseline, including ones that were matching. From that commit forward, the suite asserts the current appearance — bugs included — and it cannot tell you it stopped working.

**Why `changed` over `all`:** `changed` rewrites only mismatched images and creates missing ones; `all` rewrites everything it executes, including matching baselines, quietly resetting subjects you never inspected.
npx playwright test --project=desktop-light # 为每个失败输出预期/实际/差异 npx playwright show-report # 检查每一处差异 npx playwright test --update-snapshots=changed # 仅在理解每一处差异后执行

**劣势:** 全盘重新生成会重写所有基线,包括原本匹配的基线。从该提交开始,测试套件会断言当前外观 —— 包括错误 —— 并且无法再报告问题。

**为什么用`changed`而非`all`:** `changed`仅重写不匹配的图像并创建缺失的图像;`all`会重写所有执行的测试,包括匹配的基线,悄悄重置你从未检查过的测试对象。

The update travels with the change

更新与变更同步

New or updated images belong in the same pull request as the code that changed the appearance, with the diff visible to the reviewer. A "update baselines" commit landed separately is unreviewable: nobody can tell an intended redesign from a regression once the two are in different diffs.
新的或更新的图像应与改变外观的代码放在同一个拉取请求中,让评审者能看到差异。单独提交"更新基线"的提交是无法审查的:一旦两者在不同的差异中,没人能区分预期的重新设计和回归错误。

Ownership is explicit

所有权明确

  • The author who changed the UI proposes baselines and states in the PR description what should have changed.
  • A second person accepts — by approving the PR (self-hosted) or by accepting in the review UI (cloud). Author self-acceptance defeats the whole mechanism.
  • Auto-accept belongs on a trunk/release branch at most, never on pull requests, and never as a way to clear a red build.
See examples/ci.md for the human-triggered baseline-refresh workflow that opens a PR instead of pushing to the branch.

  • 修改UI的作者提议基线,并在PR描述中说明预期的变更。
  • 第二个人接受 —— 通过批准PR(自托管)或在审查UI中接受(云端)。作者自行接受会破坏整个机制。
  • 自动接受最多只能用于主干/发布分支,绝不能用于拉取请求,也绝不能作为清除红色构建的方式。
查看examples/ci.md获取人工触发的基线刷新工作流,它会打开PR而非直接推送到分支。

Pattern 5: CI Wiring and Cost Control

模式5:CI集成与成本控制

When the job runs

任务运行时机

Visual checks run on pull requests against the merge base and on every trunk build (trunk builds are what keep baselines valid through merges). They do not run on documentation-only or config-only changes, and they never run with
--update-snapshots
in an automated job.
视觉检查在针对合并基准的拉取请求上运行,也在每次主干构建上运行(主干构建用于确保基线在合并过程中保持有效)。它们不应在仅修改文档或配置的变更上运行,且绝不能在自动化任务中使用
--update-snapshots

Failure output must be actionable

失败输出必须可操作

yaml
- name: Visual tests
  run: npx playwright test --project=desktop-light --project=desktop-dark
- name: Upload diffs
  if: ${{ !cancelled() }} # failure is exactly when the artifacts matter
  uses: actions/upload-artifact@v4
  with:
    name: visual-diffs
    path: |
      playwright-report/
      test-results/
Why good: the reviewer downloads one artifact containing expected/actual/diff for every failure and decides in seconds
Why bad:
if: failure()
alone skips artifacts on cancellation and timeout, and a job whose only output is "1 failed" forces a local reproduction of a container-specific render.
yaml
- name: Visual tests
  run: npx playwright test --project=desktop-light --project=desktop-dark
- name: Upload diffs
  if: ${{ !cancelled() }} # 失败时恰恰是工件最有用的时候
  uses: actions/upload-artifact@v4
  with:
    name: visual-diffs
    path: |
      playwright-report/
      test-results/
优势: 评审者下载一个工件即可获取所有失败的预期/实际/差异,几秒内就能做出判断
劣势:
if: failure()
会在取消和超时情况下跳过工件,而仅输出"1 failed"的任务会迫使评审者在本地复现容器特定的渲染。

Bound the cost on purpose

有意控制成本

  • Self-hosted: restrict snapshot projects (
    ignoreSnapshots: true
    elsewhere), shard the visual project separately from the functional suite, and prefer element captures — smaller images compare faster and diff more clearly.
  • Cloud: enable change-based targeting (
    --only-changed
    ), which uses git history plus the dependency graph to capture only subjects affected by the diff and copies the rest forward at a fraction of the snapshot cost.
  • Both: treat the matrix as a budget. Every new mode multiplies the whole corpus; add one only when a real bug class lives in that cell.
  • 自托管: 限制快照项目(其他地方设置
    ignoreSnapshots: true
    ),将视觉项目与功能套件分开分片,优先选择元素捕获 —— 更小的图像对比更快,差异更清晰。
  • 云端: 启用基于变更的目标定位(
    --only-changed
    ),它使用git历史和依赖图仅捕获受差异影响的测试对象,其余对象以快照成本的一小部分复用。
  • 两者通用: 将矩阵视为预算。每个新模式都会使整个corpus数量倍增;仅当该单元存在真实的错误类型时才添加。

Exit-code policy is a decision, not a default

退出码策略是决策,而非默认值

Failing the job on detected change forces review before merge. Passing the job on change (
--exit-zero-on-changes
) keeps the pipeline green while changes wait in the review UI — acceptable only with a required status check that blocks merge on unaccepted changes. Without that check it is an off switch with extra steps.
See examples/ci.md for the full workflow, container-parity job, and targeted-run configuration.
</patterns>
<red_flags>
检测到变更时使任务失败会强制合并前进行审查。变更时任务通过(
--exit-zero-on-changes
)会保持流水线绿色,同时变更在审查UI中等待 —— 仅当有阻止合并的未接受变更的必填状态检查时才可行。没有该检查的话,这就是一个多步骤的关闭开关。
查看examples/ci.md获取完整工作流、容器一致性任务和目标运行配置。
</patterns>
<red_flags>

RED FLAGS

红色警告

High Priority Issues:
  • Running a blanket baseline update to clear a red build — the suite now asserts whatever was on screen, including the regression, and it will never report it again
  • Capturing while the page is still settling (in-flight requests, entrance animations, unloaded fonts) — produces intermittent diffs that get "fixed" by regenerating, which is how blind regeneration becomes a habit
  • Committing baselines rendered on a developer machine — font rasterization differs from the CI container, so every subject shows a full-frame diff, the suite is declared broken, and it gets disabled
  • Loosening tolerance until the noise stops — a
    maxDiffPixelRatio
    wide enough to absorb a live counter is wide enough to absorb a missing sidebar
  • Full-page captures for single-component subjects — an unrelated header change fails every page baseline at once, and the real component regression is invisible inside the churn
  • The author accepting their own baselines — the review step that gives the baseline its meaning never happens
Medium Priority Issues:
  • No
    expected/actual/diff
    artifact on failure — reviewers cannot judge, so they regenerate
  • Visual assertions mixed into functional test files — one flaky image blocks feedback on unrelated behaviour
  • Matrix growth without justification — each mode multiplies the corpus, its maintenance, and (in cloud harnesses) the bill
  • Masking so much of the frame that the remaining pixels prove nothing — at that point delete the test
  • Trunk never runs the visual job — branch baselines have nothing valid to merge into
Common Mistakes:
  • Using a screenshot to assert text or presence — use a text/role assertion; it fails with a sentence instead of an image
  • Setting
    animations: "disabled"
    on every call when the screenshot assertion already defaults to disabled
  • Naming baselines after test order (
    step-3.png
    ) — reordering silently reassigns approved images to different subjects
  • Treating an image diff as "flaky" without identifying which of fonts/motion/time/randomness/data/scrollbars caused it
  • Storing baselines outside the repo for a self-hosted harness — the approved image must move with the code that produced it
Gotchas & Edge Cases:
  • mask
    and
    maskColor
    are per-call only; the config-level
    expect.toHaveScreenshot
    block accepts
    animations
    ,
    caret
    ,
    maxDiffPixels
    ,
    maxDiffPixelRatio
    ,
    scale
    ,
    stylePath
    ,
    threshold
    , and
    pathTemplate
    — nothing else
  • The screenshot assertion defaults to
    animations: "disabled"
    ,
    caret: "hide"
    ,
    scale: "css"
    ,
    threshold: 0.2
    — most per-call option noise is restating defaults
  • Masked regions are filled with
    #FF00FF
    by default; if the UI legitimately contains that colour the mask is invisible in the diff — set
    maskColor
    explicitly
  • clip
    and
    fullPage
    exist only on the page-level assertion; the element-level assertion has neither — capture the element directly instead
  • The assertion self-stabilizes by re-capturing until two consecutive frames match, then diffing the last one — that removes render jitter, not application nondeterminism
  • updateSnapshots
    defaults to
    'missing'
    ;
    'all'
    rewrites matching baselines too, and
    'none'
    makes a missing baseline a hard failure (useful in CI)
  • The project name is part of the baseline path — renaming a project orphans every image under it and the next run happily creates fresh "baselines"
  • Cloud modes are keyed by name: changing a mode's viewport under the same name keeps comparing against the old baseline, while renaming a mode starts a brand-new one
  • Cloud animation handling pauses CSS animations at the last frame by default (
    pauseAnimationAtEnd: true
    ) — pre-2024 guidance describing first-frame capture is stale
  • The cloud default diff sensitivity (
    diffThreshold
    , default
    0.063
    ) is a different scale and a different algorithm from the self-hosted
    threshold
    (
    0.2
    ) — do not port numbers between harnesses
  • Change-based targeting depends on a lockfile in sync with the manifest and on intact git history; rebases, squashes, and force pushes fall back to full rebuilds
  • .webp
    baselines cut repository growth substantially versus PNG when the corpus is large
</red_flags>

<critical_reminders>
高优先级问题:
  • 通过全盘更新基线来清除红色构建 —— 测试套件现在会断言当前屏幕内容,包括回归错误,且永远不会再报告它
  • 页面仍在加载时捕获(请求进行中、入场动画、未加载的字体) —— 产生间歇性差异,然后通过重新生成"修复",这会让盲目重新生成成为习惯
  • 提交在开发者机器上渲染的基线 —— 字体光栅化与CI容器不同,导致每个测试对象显示全帧差异,测试套件被判定为损坏并被禁用
  • 放宽容差直到噪声停止 —— 宽到足以容纳实时计数器的
    maxDiffPixelRatio
    ,也足以掩盖缺失的侧边栏
  • 为单个组件测试对象进行全页捕获 —— 无关的页眉变更会导致所有页面基线失败,真正的组件回归在混乱中不可见
  • 作者自行接受自己的基线 —— 赋予基线意义的审查步骤从未执行
中优先级问题:
  • 失败时无
    expected/actual/diff
    工件 —— 评审者无法判断,因此会重新生成基线
  • 视觉断言混入功能测试文件 —— 一张不稳定的图像会阻止无关功能的反馈
  • 矩阵无理由增长 —— 每个模式都会倍增corpus、维护成本和(云端工具的)账单
  • 屏蔽过多帧内容,剩余像素无法证明任何东西 —— 此时应删除测试
  • 主干从未运行视觉测试任务 —— 分支基线没有有效的合并目标
常见错误:
  • 使用截图断言文本或存在 —— 使用文本/角色断言;它会返回句子而非图像
  • 每次调用都设置
    animations: "disabled"
    ,而截图断言默认已禁用动画
  • 按测试顺序命名基线(
    step-3.png
    ) —— 重新排序会悄悄将已批准图像分配给不同的测试对象
  • 未确定是字体/动画/时间/随机性/数据/滚动条中的哪一个导致的,就将图像差异视为"偶发故障"
  • 自托管工具的基线存储在仓库外 —— 已批准图像必须与生成它的代码同步
陷阱与边缘情况:
  • mask
    maskColor
    仅能每个调用设置;配置级
    expect.toHaveScreenshot
    块接受
    animations
    caret
    maxDiffPixels
    maxDiffPixelRatio
    scale
    stylePath
    threshold
    pathTemplate
    —— 不接受其他参数
  • 截图断言默认设置为
    animations: "disabled"
    caret: "hide"
    scale: "css"
    threshold: 0.2
    —— 大多数每个调用的选项都是重复默认值
  • 屏蔽区域默认填充
    #FF00FF
    ;如果UI中确实包含该颜色,差异中会看不到屏蔽 —— 需显式设置
    maskColor
  • clip
    fullPage
    仅存在于页面级断言中;元素级断言没有这两个参数 —— 直接捕获元素
  • 断言会自动稳定,通过重新捕获直到连续两帧匹配,然后对比最后一帧 —— 这会消除渲染抖动,但不会解决应用的非确定性
  • updateSnapshots
    默认为
    'missing'
    'all'
    也会重写匹配的基线,
    'none'
    会将缺失的基线视为严重失败(CI中有用)
  • 项目名称是基线路径的一部分 —— 重命名项目会使所有图像成为孤儿,下次运行会愉快地创建新的"基线"
  • 云端模式按名称键控:在相同名称下修改模式的视口仍会与旧基线对比,而重命名模式会开始全新的基线
  • 云端动画处理默认在最后一帧暂停CSS动画(
    pauseAnimationAtEnd: true
    ) —— 2024年前描述第一帧捕获的指南已过时
  • 云端默认差异敏感度(
    diffThreshold
    ,默认
    0.063
    )与自托管
    threshold
    0.2
    )的刻度和算法不同 —— 不要在工具间移植数值
  • 基于变更的目标定位依赖于与清单同步的锁文件和完整的git历史;变基、压缩和强制推送会回退到全量构建
  • 当corpus较大时,
    .webp
    基线相比PNG能大幅减少仓库增长
</red_flags>

<critical_reminders>

CRITICAL REMINDERS

重要提醒

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type
, named constants)
(You MUST look at the diff before accepting any baseline — an unreviewed baseline permanently encodes whatever was on screen, including the regression)
(You MUST make the render deterministic before capturing — fonts loaded, animations stopped, time frozen, data fixed — or the diff reports noise instead of regressions)
(You MUST generate and compare baselines in the same environment — same pinned container image, same browser build — never commit baselines rendered on a developer machine)
(You MUST scope the capture to the subject under test — an element or a clip — and reserve full-page captures for cases where the page itself is the subject)
(You MUST mask dynamic regions rather than loosening the comparison threshold — a threshold wide enough to absorb a live timestamp is wide enough to absorb a broken layout)
Failure to follow these rules will produce a suite that asserts the current bugs, fails at random, and gets deleted by the next person who owns it.
</critical_reminders>
所有代码必须遵循CLAUDE.md中的项目约定(短横线命名法、命名导出、导入顺序、
import type
、命名常量)
(接受任何基线前必须查看差异 —— 未经审查的基线会永久固化当前屏幕内容,包括回归错误)
(捕获截图前必须确保渲染的确定性 —— 字体加载完成、动画停止、时间冻结、数据固定 —— 否则差异报告会显示噪声而非回归错误)
(必须在相同环境中生成和对比基线 —— 使用相同的固定容器镜像、相同的浏览器版本 —— 绝不要提交在开发者机器上渲染的基线)
(必须将捕获范围限定在测试对象上 —— 单个元素或裁剪区域 —— 仅当页面本身为测试对象时才进行全页捕获)
(必须屏蔽动态区域而非放宽对比阈值 —— 宽到足以容纳实时时间戳的阈值,也足以掩盖布局崩溃问题)
不遵循这些规则会导致测试套件断言当前错误、随机失败,并被下一位维护者删除。
</critical_reminders> ",