Metabase 可视化回归测试实战:基于 Loki 与 Storybook 的 Visual Tests 完整指南
【免费下载链接】metabaseThe easy-to-use open source Business Intelligence and Embedded Analytics tool that lets everyone work with data :bar_chart:项目地址: https://gitcode.com/GitHub_Trending/me/metabase
导读
Visual Tests(视觉回归测试)是 Metabase 前端质量保障体系中不可或缺的一环,用于捕捉组件和图表在代码改动后出现的“肉眼可见但单元测试无法发现”的样式回归。Metabase 选用 Loki 为骨架,结合仓库中的 loki.config.js、package.json、.storybook/main.ts、.storybook/preview.tsx 以及 GitHub Actions 工作流,系统讲解本地运行、CI 集成、新增测试与排障全流程,读完即可在 Metabase 仓库中独立开展视觉回归测试。
一、技术栈与运行原理
1.1 为什么是 Loki + Storybook
Metabase 的前端体量巨大(frontend/src/metabase下约 9800 个文件),图表可视化(frontend/src/metabase/visualizations)等模块极易在重构、主题定制或样式调整时产生细微渲染差异。Loki 的设计思路是:以 Storybook 为渲染容器,用真实浏览器引擎截取页面快照,再与历史参考图做像素级 diff。
其核心工作流为:
- Storybook 按 story 描述渲染组件(含 mock 数据与全局样式装饰器);
- Loki 通过 Chrome(Docker 容器内)逐故事截图,输出到
.loki/current; - 与
.loki/reference中的 PNG 参考图比对,差异写入.loki/difference; - 比对引擎与容差由 loki.config.js 控制。
1.2 三个关键目录
| 目录 | 作用 |
|---|---|
.loki/reference | 已批准的参考截图(基线),由 CI 或人工确认后维护 |
.loki/current | 本次运行新截取的截图 |
.loki/difference | 与参考图不一致的差异图,用于人工复核 |
从 frontend/test/generate-loki-report-json.js 的源码可以看到,报告脚本正是分别读取reference、current、difference三个目录,将其中的差异项写入.loki/report.json,再交给reg-cli渲染成 HTML 报告。
二、本地运行 Visual Tests
2.1 前置条件
在本地运行前,需要同时满足:
- Storybook 正在运行:Loki 默认向本地 Storybook 实例发起截图请求;
- Docker 正在运行:Loki 使用
chrome.docker作为截图目标,见 loki.config.js 中chrome.laptop配置对target: "chrome.docker"的引用。
2.2 常用命令
仓库在 package.json 中预置了整套 npm scripts,核心命令如下:
# 1. 本地运行视觉测试(开发环境 NODE_ENV=development) bun run test-visual:loki # 2. 以 CI 方式运行:先构建 Storybook 静态站,再对静态站截图 bun run test-visual:loki:ci # 3. 将失败的截图(.loki/difference)复制为新的参考图,更新基线 bun run test-visual:loki-approve-diff # 4. 仅生成 HTML 报告(不重新运行测试) bun run test-visual:loki-report # 5. 先运行测试,若失败则生成并自动打开 HTML 报告 bun run test-visual:loki-report-open各命令底层实现(来自 package.json):
"test-visual:loki": "NODE_ENV=development loki test --chromeFlags='--headless --disable-gpu'", "test-visual:loki:ci": "bun run build-storybook && bun run test-visual:loki --reactUri file:./storybook-static --verboseRenderer", "test-visual:loki-approve-diff": "ls .loki/difference | xargs -I _ find .loki/current -name _ | xargs -I _ cp _ .loki/reference/", "test-visual:loki-prune": "ls .loki/reference | grep -v \"$(ls .loki/current)\" | xargs -I {} rm .loki/reference/{}", "test-visual:loki-report": "node frontend/test/generate-loki-report-json.js && reg-cli --from .loki/report.json --report .loki/report.html", "test-visual:loki-report-open": "bun run test-visual:loki || (echo 'Visual test failed, opening report...' && bun run test-visual:loki-report && open-cli .loki/report.html)"要点说明:
test-visual:loki使用开发环境构建(NODE_ENV=development),并显式传入--headless --disable-gpu保证无头截图稳定;test-visual:loki:ci是官方推荐的基线生成方式:原文档特别提示,本地运行得到的截图不要直接提交,应使用 CI 变体生成参考图,因为 CI 环境的字体、渲染栈更一致,参考图更稳定;test-visual:loki-approve-diff将差异图对应位置的当前截图覆盖到reference目录,实现“一键批准”;test-visual:loki-prune反向清理:删除参考图中已不存在的多余基线;- 报告最终落盘为
.loki/report.html,可离线打开审查。
2.3 本地差异审查流程
bun run test-visual:loki-report-open该命令先跑一轮测试:若全部通过则无事发生;若存在差异,自动生成报告并调用open-cli在浏览器打开.loki/report.html,即可在红绿对比中逐项确认是真实回归还是合理变更。
三、CI 集成:Pull Request 自动触发
3.1 工作流概览
视觉测试在 Pull Request 上自动触发,核心工作流为 .github/workflows/loki.yml:
- files-changed阶段:通过
dorny/paths-filter依据 .github/file-paths.yaml 判断本次改动是否涉及前端源码、Loki 相关文件(.github/workflows/loki.yml、.loki/**)或前端 CI 基础设施; - visual-test 阶段:仅在满足
frontend_ci、frontend_sources或frontend_loki_ci任一条件时执行:- 启动 Docker 服务容器(
docker:19.03.12,privileged 模式)供 Loki 的 Chrome 使用; - 依次准备前端/后端环境、编译 CLJS(
NODE_ENV=development bun run build-pure:cljs); - 运行
bun run test-visual:loki:ci; - 失败时生成视觉报告并上传
loki-reportartifact(包含.loki/整个目录,含隐藏文件)。
- 启动 Docker 服务容器(
3.2 失败时如何查看差异
当 PR 上出现 "Loki Visual Regression Testing" 检查失败时:
- 打开失败 Job 页面;
- 进入Summary区域;
- 下载
loki-report构件; - 解压后打开其中的
report.html,逐条比对difference目录中的差异截图。
3.3 如何更新参考图(批准差异)
若差异是有意为之(如设计改版)或偶发不稳定(flake),无需手动下载构件,只需给 PR 打上loki-update标签,CI 便会以当前截图更新参考基线。这是团队推荐的“批量批准”方式,避免人工逐个复制差异图。
3.4 智能裁剪:受影响的 story 才会被跑
值得注意的细节:仓库通过 .github/scripts/create-test-plan.ts 和 .github/scripts/affected-tests.ts 构建“测试计划”。其中 Loki 相关的 story 列表来自frontend/**/*.stories.{js,jsx,ts,tsx}与enterprise/frontend/**/*.stories.{js,jsx,ts,tsx}(见 create-test-plan.ts),再结合依赖图(dependency-cruiser)与改动文件推断本次应执行的 Loki story 子集(loki_stories_to_run),并将结果输出供工作流消费。这意味着 CI 并非每次全量截图,而是基于改动影响面做智能裁剪,从而显著缩短反馈周期。
四、新增 Visual Test:写一个 story 即可
4.1 最小实践
新增视觉测试不需要额外测试代码,本质就是新增 Storybook story。原文档明确指出:当前视觉测试仅用于图表,但任何 story 都可纳入。唯一要求是确保loki.config.js中的storiesFilter覆盖到目标 story。
以仓库真实示例 BarChart.stories.tsx 为模板:
import type { StoryFn } from "@storybook/react"; import { VisualizationWrapper } from "__support__/storybook"; import { NumberColumn, StringColumn } from "__support__/visualizations"; import Visualization from "metabase/visualizations/components/Visualization"; import { registerVisualization } from "metabase/viz-core"; import type { Series } from "metabase-types/api"; import { createMockCard } from "metabase-types/api/mocks"; import { BarChart } from "./BarChart"; export default { title: "viz/BarChart", component: BarChart, }; registerVisualization(BarChart); const MOCK_SERIES = [ { card: createMockCard({ name: "Card", display: "bar" }), data: { cols: [StringColumn({ name: "Dimension" }), NumberColumn({ name: "Count" })], rows: [["foo", 1], ["bar", 2]], }, }, ] as Series; const DefaultTemplate: StoryFn = () => ( <VisualizationWrapper> <Box h={500}> <Visualization rawSeries={MOCK_SERIES} width={500} /> </Box> </VisualizationWrapper> ); export const Default = { render: DefaultTemplate, parameters: { loki: { skip: true }, // 需要纳入 Loki 时移除该参数 }, };同时,在 loki.config.js 的storiesFilter中加入对应的 story 标题模式(例如"^viz/BarChart"),该字段是一个以|连接的正则表达式数组(代码中通过.join("|")合并),支持前缀锚定与精确匹配。
4.2 按需跳过:loki: { skip: true }
并非所有 story 都适合截图。仓库中BarChart的Default与Watermark两个 story 均设置了parameters.loki.skip = true(见 BarChart.stories.tsx),原因通常是:
- 图表含动态动画/异步加载,截图不稳定;
- 依赖用户交互态(hover、滚动);
- 涉及外部字体、图片等非确定性渲染。
此类 story 通过 Storybook 的parameters.loki字段在渲染侧被 Loki 跳过,无需改动loki.config.js。
4.3 让截图确定性的工程细节
视觉测试最怕“时好时坏”。Metabase 在 Storybook 预览层做了大量确定性保障,见 .storybook/preview.tsx:
- 去掉人为延迟:
window.METABASE_REMOVE_DELAYS = true,跳过 story 中的可跳过 delay; - 同步加载 ECharts:注释明确指出 EChartsRenderer 在应用中按需分包加载,若不在 Storybook 中强制同步引入,快照会拍到“懒加载骨架屏闪烁”;
- 字体预加载:在预览加载时同步注入
@font-face,并通过fontsReadyloader 等待所有字体load()完成。注释解释:若不等待,表格列宽自动计算(依赖字体度量)会在不同机器上产生不同结果,导致截图不一致; - 同步加载全部可视化组件:通过
loadVisualizationComponents()loader 确保图表组件注册完成,避免截图时组件仍处于 Suspense 骨架状态。
而 .storybook/main.ts 则支持环境变量STORYBOOK_STORIES_FILTER(逗号分隔的 story 文件路径),用于只构建指定的 story 文件——这是下方压力测试工作流的核心依赖。
4.4 渲染到图片的场景如何配合 Loki
部分 story 需要把图表导出为图片(如 PDF/PNG 导出场景),仓库提供了openImageBlobOnStorybook工具(frontend/src/metabase/utils/loki-utils.ts):它将导出的 blob 生成<img>挂到#storybook-root,并添加data-testid="image-downloaded"标记,直到图片完全加载后才触发就绪信号,从而保证 Loki 截图时画面上呈现的是完整导出的图片而非空白或半加载状态。该工具被 save-chart-image.ts 与 save-dashboard-pdf.ts 在 Storybook/Loki 环境下复用。
五、合并前必做:Loki Visual Stress Test
5.1 为什么需要压力测试
视觉测试天然受字体、GPU 渲染、时序影响,单次通过不代表稳定。原文档明确要求:合并 PR 前,运行 Loki Visual Stress Test 工作流验证新增测试不 flaky。
5.2 工作流用法
工作流为 .github/workflows/loki-stress-test-flake-fix.yml,支持两种触发方式:
- PR 自动触发:当 PR 改动
frontend/**/*.stories.tsx或enterprise/frontend/**/*.stories.tsx时自动运行(detect-changed-stories会调用 GitHub API 找出本次变更的 story 文件); - 手动触发(workflow_dispatch):填写两个输入项——
story_files:相对于仓库根目录、逗号分隔的 story 文件路径(必须匹配frontend/或enterprise/frontend/前缀且包含.stories.,否则会校验报错);burn_in:重复运行次数,例如10,默认10。
原文档提到的“运行 50 次”即通过burn_in输入实现。
5.3 工作流内部逻辑
其核心stress-test-lokiJob 展示了官方判定 flake 的标准流程:
- 用
STORYBOOK_STORIES_FILTER环境变量只构建变更的 story 文件(复用 .storybook/main.ts 的过滤逻辑); - 循环
seq 1 $BURN_IN,每轮执行bun run test-visual:loki --reactUri file:./storybook-static --verboseRenderer; - 注意
set -o pipefail防止管道吞掉 Loki 的退出码(注释明确说明:tee成功后管道会误报成功); - 单轮失败时把
.loki/difference拷入.loki/failures/run-$i留存现场; - 任何一轮失败都会导致整个 Job 失败并输出
X out of N runs failed错误; - 特殊情况:若日志中出现
No stories were found,说明改动文件里的 story 全部被 Loki 跳过,直接以成功退出。
若压力测试通过率不达标,说明 story 存在渲染不确定性,应回到 4.3 节的确定性保障手段排查(字体、异步、动画、懒加载等),而不是直接放宽容差。
六、配置详解:loki.config.js
.loki.config.js 是 Loki 行为的唯一事实来源,当前仓库配置如下:
module.exports = { diffingEngine: "looks-same", storiesFilter: [ "DataGrid", "static-viz", "viz", "Patterns/Upsells", "^visualizations/shared", "^app/embed", "^design system", "^Components/Overlays/Menu Hover state", "^Components/Overlays/Popover Opened", "^Components/Overlays/Modal Opened", "^Components/Overlays/HoverCard Opened", "^Components/Utils/Paper Shadow matrix", "^Components/Data display/Card Shadow matrix", "^Components/Inputs/Checkbox (Overview|Checkbox\\.Card)$", "^Components/Inputs/DatePicker Dates range", "^Components/Inputs/Radio (Overview|Radio\\.Card)$", "^Components/Inputs/Switch (Overview|Switch\\.Group)$", "^Components/Parameters/DatePicker", "^Components/Buttons/Button Compact size, custom color", "^Components/Overlays/Tooltip", "^Components/Documents", "^Components/Feedback/Alert", "^Components/Feedback/Loader Overview", "^Components/Ask Before Using/Chip Overview", "^Components/Data display/Badge Sizes and variants", "^Components/Navigation/NavLink Overview", "^Components/Data display/KeyboardShortcut Overview", "^Components/Table", "^App/Palette", "^viz/GridMapPdfExport", "ParameterValueWidget", "^Explorations/ExplorationGroupVisualization", ].join("|"), configurations: { "chrome.laptop": { target: "chrome.docker", width: 1366, height: 768, deviceScaleFactor: 1, mobile: false, }, }, "looks-same": { strict: false, antialiasingTolerance: 9, tolerance: 9, }, };逐项解读:
diffingEngine: "looks-same":选用looks-same(Yandex 出品的像素比对库)作为 diff 引擎;storiesFilter:正则数组,^表示以某前缀开头的 story 标题(如^viz/GridMapPdfExport),未加锚的条目(如DataGrid)则按子串/前缀语义匹配。新增测试时修改此数组是最常见的操作;同时注意 .storybook/preview.tsx 的注释:story 名称变更可能影响 Loki 测试,任何重命名都要同步更新storiesFilter;configurations."chrome.laptop":定义截图视口为 1366×768(笔记本分辨率),deviceScaleFactor: 1保证 1:1 像素输出,target: "chrome.docker"说明实际渲染由 Docker 内的 Chrome 完成——这正是本地运行要求 Docker 的原因;looks-same容差:strict: false关闭严格模式;antialiasingTolerance与tolerance均为 9,允许亚像素级的抗锯齿差异存在,从而容忍不同平台的字体渲染差异,避免高频误报。
容差参数的影响
tolerance是像素级颜色差异阈值,值越大越宽松。Metabase 设为 9 属于“相对严格但容忍抗锯齿”的折中;antialiasingTolerance单独处理边缘像素的混色差异;- 若你的图表频繁出现“时有时无”的失败,优先排查渲染确定性,而不是盲目调大
tolerance,否则会漏掉真实回归。
七、常见问题与排障路径
| 现象 | 排查方向 |
|---|---|
| 本地报错要求 Docker | 检查 Docker daemon 是否运行,Loki 的chrome.dockertarget 依赖 Docker |
| Storybook 未启动导致截图失败 | 先启动 Storybook dev server,或改用test-visual:loki:ci(自建静态站) |
| 本地通过但 CI 失败 | 本地截图与 CI 渲染环境不一致,按官方建议以 CI 生成的参考图为准,勿提交本地截图 |
| 新增 story 未被截图 | 检查loki.config.js的storiesFilter是否覆盖该 story 标题 |
| 测试偶发失败(flake) | 运行压力测试工作流定位;检查字体预加载、动画/延迟、懒加载与异步图表渲染 |
| 参考图过期(story 已删) | 用bun run test-visual:loki-prune清理多余基线 |
| 有意改版需更新基线 | PR 打loki-update标签批量更新,或本地test-visual:loki-approve-diff |
| 修改了 story 名称 | 同步更新 loki.config.js 的storiesFilter,否则截图对不上基线 |
八、总结
Metabase 的 Visual Tests 体系由三层构成:
- 渲染层:Storybook 提供确定性的组件渲染环境(字体预加载、同步图表、去延迟),详见 .storybook/preview.tsx;
- 截图与比对层:Loki 借助 Docker 内 Chrome 在 1366×768 视口下截图,由 loki.config.js 的
storiesFilter决定覆盖范围、looks-same容差决定敏感度; - CI 与流程层:.github/workflows/loki.yml 在 PR 上自动执行并以
loki-report构件交付差异报告,loki-stress-test-flake-fix.yml 通过多轮重复运行(如 50 次burn_in)把 flake 扼杀在合并之前,.github/scripts/create-test-plan.ts 则保证只运行受改动影响的 story,兼顾覆盖与效率。
对开发者而言,日常涉及的三条黄金规则是:本地只做快速验证、参考图一律以 CI 生成为准、合并前跑压力测试。遵循这套流程,即可在保证 Metabase 图表与组件视觉一致性的同时,把误报和 flake 控制在可接受范围内。
关联阅读:docs/developers-guide/visual-tests.md(本文依据)、frontend/test/generate-loki-report-json.js(报告生成实现)、.github/file-paths.yaml(Loki 相关文件变更判定)。
【免费下载链接】metabaseThe easy-to-use open source Business Intelligence and Embedded Analytics tool that lets everyone work with data :bar_chart:项目地址: https://gitcode.com/GitHub_Trending/me/metabase
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考