OpenScreen 测试体系实战:用 Vitest 双配置写单元测试与真实浏览器测试
OpenScreen 是一个基于 Electron + React 的开源录屏与视频编辑工具,其核心导出管线依赖 WebCodecs、MediaRecorder、OffscreenCanvas 等真实浏览器 API,无法在纯 Node 环境下验证。为此项目建立了一套"双配置"的 Vitest 测试体系:vitest.config.ts 面向 jsdom 环境的单元测试,vitest.browser.config.ts 面向 Playwright 驱动的无头 Chromium 浏览器测试。读完后,你将掌握如何根据代码是否依赖真实浏览器 API 来选择测试类型、如何按项目规范放置测试文件、如何加载测试固件(fixture)资源,以及如何在本地一键运行两套测试。
双配置体系总览
项目使用 Vitest 同时承担单元测试/集成测试与浏览器测试,两套配置各管各的文件,互不干扰:
| 单元测试 | 浏览器测试 | |
|---|---|---|
| 配置文件 | vitest.config.ts | vitest.browser.config.ts |
| 运行环境 | jsdom(模拟 DOM,无真实浏览器) | 真实 Chromium(Playwright 驱动,无头模式) |
| 文件匹配 | *.test.* / *.spec.*(排除 .browser.test.*) |
src/**/*.browser.test.ts(x) |
| 本地命令 | npm run test(一次)、npm run test:watch(监听) |
先 npm run test:browser:install,再 npm run test:browser |
两套配置在 package.json 中对应明确的脚本入口:
"test": "vitest --run",
"test:watch": "vitest",
"test:browser": "vitest --config vitest.browser.config.ts --run",
"test:browser:install": "playwright install --with-deps chromium-headless-shell"
其中 test:browser:install 是首次运行前的一次性步骤,用于下载 Chromium headless shell 及其系统依赖;日常运行只需 test:browser。
单元测试:jsdom 环境与纯逻辑验证
配置与适用场景
单元测试运行在 jsdom 提供的模拟 DOM 中,适合纯函数、工具函数、数据转换,以及一切不依赖真实浏览器 API(Canvas、WebCodecs、MediaRecorder 等)的代码。vitest.config.ts 的关键配置如下:
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "jsdom",
include: ["{src,electron}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
exclude: ["src/**/*.browser.test.{ts,tsx}"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
});
几个值得注意的细节:
globals: true开启全局 API,但项目中的测试文件仍然显式import { describe, expect, it } from "vitest",两种写法并存;- 实际的匹配范围是
{src,electron}两个目录下的*.test.*和*.spec.*文件——从源码看,主进程(Electron side)代码同样被纳入单元测试范围,例如 recordingStream.test.ts 就在electron/ipc/下直接测试RecordingStreamRegistry的流式落盘逻辑; exclude显式排除src/**/*.browser.test.ts(x),保证两套配置的文件集合互斥,不会重复执行;@别名指向src/,与浏览器测试配置保持一致。
文件放置约定
测试文件与源码文件同目录放置(co-locate),或放入同目录下的 __tests__/ 文件夹集中管理:
src/lib/compositeLayout.ts
src/lib/compositeLayout.test.ts # co-located
src/i18n/__tests__/tutorialHelpTranslations.test.ts # grouped
典型示例:布局计算的单元测试
compositeLayout.test.ts 测试的是导出画布中屏幕与摄像头窗口的布局计算函数 computeCompositeLayout——这是纯几何计算,天然适合单元测试。项目文档给出的最小示例:
import { describe, expect, it } from "vitest";
import { computeCompositeLayout } from "./compositeLayout";
describe("computeCompositeLayout", () => {
it("anchors the overlay in the lower-right corner", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
});
expect(layout).not.toBeNull();
expect(layout!.webcamRect!.x).toBeGreaterThan(1920 / 2);
expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2);
});
});
实际仓库中的该文件覆盖了更多边界场景,可作为编写高质量单元测试的参考模板:
- 越界约束:断言
webcamRect的右下角不超过画布尺寸(x + width <= 1920、y + height <= 1080); - 参数钳制:验证
webcamSizePreset被钳制在 10–50 的有效范围内,传入1与传入10的结果一致,传入100与传入50的结果一致; - 横竖屏一致性:同像素总量下,1920×1080 横屏与 1080×1920 竖屏产生的摄像头窗口面积应完全相等;
- 布局预设:分别验证
vertical-stack(上下堆叠)与dual-frame(双框 2:1 分屏)两种布局下的具体坐标与比例; - 蒙版形状:圆形/方形蒙版强制宽高相等,
rounded蒙版的borderRadius大于rectangle。
i18n 键值覆盖率测试
文档将"i18n key coverage"列为单元测试场景,仓库中的对应实现是 tutorialHelpTranslations.test.ts。它的做法是:导入全部 13 个语言包(与 config.ts 中 SUPPORTED_LOCALES 定义的 en、ar、es、fr、it、ja-JP、ko-KR、ru、tr、vi、pt-BR、zh-CN、zh-TW 一致),遍历一组固定的 tutorialHelpKeys 键列表,断言每个语言包的每个键都存在、是字符串且(除白名单内的可空键外)非空:
for (const locale of SUPPORTED_LOCALES) {
const tutorial = dialogsByLocale[locale].tutorial;
for (const key of tutorialHelpKeys) {
const message = tutorial[key];
const label = `${locale} dialogs.tutorial.${key}`;
expect(message, label).toEqual(expect.any(String));
if (!keysThatMayBeEmpty.has(key)) {
expect((message as string).trim().length, label).toBeGreaterThan(0);
}
}
}
expect 的第二个参数 label 会在断言失败时打印出"哪个语言包的哪个键"出了问题,这是批量校验类测试的可读性技巧。
路径别名
@/ 别名解析到 src/,用于替代冗长的相对路径导入:
import { SUPPORTED_LOCALES } from "@/i18n/config";
本地运行
npm run test # 运行一次
npm run test:watch # watch 模式
浏览器测试:Playwright 无头 Chromium 与真实 Web API
配置细节:为什么要开 SwiftShader
当被测代码依赖 jsdom 没有实现的真浏览器 API——VideoDecoder、VideoEncoder、MediaRecorder、OffscreenCanvas、WebGL 等——时,应写浏览器测试。vitest.browser.config.ts 的完整内容很短,但每个参数都有存在理由:
import path from "node:path";
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.browser.test.{ts,tsx}"],
browser: {
enabled: true,
provider: playwright({
launch: {
// Software WebGL so Pixi.js works in headless CI without a GPU.
args: ["--enable-unsafe-swiftshader", "--use-gl=swiftshader"],
},
}),
headless: true,
instances: [{ browser: "chromium" }],
},
testTimeout: 120_000,
hookTimeout: 30_000,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
assetsInclude: ["**/*.webm"],
});
- 软件 WebGL:
--enable-unsafe-swiftshader与--use-gl=swiftshader两个启动参数让无头 CI 环境在没有 GPU 的情况下也能跑 Pixi.js 的 WebGL 渲染——这是该项目渲染引擎的硬性前提,本地有 GPU 的环境可忽略此问题,CI 上则必须; - 超时设置:每个测试 120 秒(
testTimeout: 120_000)、每个 hook 30 秒(hookTimeout: 30_000)。文档提示:导出操作本身很慢,因此要优先使用小尺寸固件(320×180)和低码率来保持测试快速; assetsInclude: ["**/*.webm"]:让 Vite 把.webm识别为可导入的静态资源,配合?url导入语法工作(下文说明)。
文件放置约定
文件名固定为 <subject>.browser.test.ts,并与源码同目录:
src/lib/exporter/videoExporter.ts
src/lib/exporter/videoExporter.browser.test.ts
当前仓库中该规则下的文件包括 videoExporter.browser.test.ts、gifExporter.browser.test.ts 和 streamingDecoder.test.ts 之外的 videoDecoder 相关用例,均以导出管线为测试对象。
加载固件资源(fixture)
视频、图片等静态资源统一放在 tests/fixtures/ 目录(当前包含 sample.webm 与 sample-inflated-duration.webm),通过 Vite 的 ?url 后缀导入,由 dev server 在浏览器中提供:
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
?url 返回的是 dev server 上的资源 URL,浏览器测试里的真实 Chromium 可以直接用 <video>/fetch 拉取——这是 jsdom 环境做不到的关键一步。
示例:从真实视频导出 MP4 Blob
文档给出的浏览器测试示例,验证 VideoExporter 从真实视频导出一份合法 MP4:
import { describe, expect, it } from "vitest";
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
import { VideoExporter } from "./videoExporter";
describe("VideoExporter (real browser)", () => {
it("exports a valid MP4 blob from a real video", async () => {
const exporter = new VideoExporter({
videoUrl: sampleVideoUrl,
width: 320,
height: 180,
frameRate: 15,
bitrate: 1_000_000,
wallpaper: "#1a1a2e",
zoomRegions: [],
showShadow: false,
shadowIntensity: 0,
showBlur: false,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
});
const result = await exporter.export();
expect(result.success, result.error).toBe(true);
expect(result.blob).toBeInstanceOf(Blob);
});
});
注意参数取值对测试速度的影响:width: 320 / height: 180 的小画布、frameRate: 15 的低帧率、bitrate: 1_000_000(1 Mbps)的低码率,正是文档"用小尺寸 + 低码率保持测试快速"建议的落地。
实际仓库中的 videoExporter.browser.test.ts 在此基础上做了三处深化,值得借鉴:
- 魔数校验:不只看
Blob存在,还解码字节流检查 MP4 的ftypbox——bytes.slice(4, 8)解码后应等于"ftyp",确认产出物是真实可识别的 MP4 容器而非空壳; - 进度事件断言:通过
onProgress回调收集ExportProgress事件,断言phase === "finalizing"的事件存在且最后一个percentage为 100,把 UI 进度反馈也纳入测试; - 负向路径:传入不存在的壁纸路径
/wallpapers/does-not-exist.jpg,断言导出以BackgroundLoadError拒绝(reject),且错误对象携带出错的url——验证"加载失败时不静默回退为黑底"这一产品行为。gifExporter.browser.test.ts 采用同样的模式,另外用/^GIF8[79]a/正则校验 GIF 文件头。
本地运行
首次运行先安装浏览器(一次性):
npm run test:browser:install
之后每次运行:
npm run test:browser
如何选择合适的测试类型
文档给出的决策表:
| 场景 | 使用 |
|---|---|
| 纯函数 / 数据转换 | 单元测试 |
| i18n 键覆盖率 | 单元测试 |
| React hook 逻辑(不依赖真实浏览器 API) | 单元测试 |
VideoDecoder / VideoEncoder / MediaRecorder |
浏览器测试 |
OffscreenCanvas / WebGL / Pixi.js 渲染 |
浏览器测试 |
产出真实 Blob 的文件导出 |
浏览器测试 |
判断的核心问题只有一个:被测代码是否触碰到 jsdom 无法实现的浏览器 API? 是则写 .browser.test.ts,否则写普通 .test.ts。两套配置的文件互斥保证每次 vitest 调用只跑其中一类,避免在无浏览器环境下误跑浏览器测试。
小结与延伸阅读
- 两套配置:vitest.config.ts(jsdom 单元测试)与 vitest.browser.config.ts(Playwright 无头 Chromium 浏览器测试),通过文件后缀约定互斥;
- 单元测试适合纯逻辑、i18n 覆盖、hook 逻辑与 Electron 主进程逻辑(
{src,electron}均在匹配范围内); - 浏览器测试适合 WebCodecs、WebGL/Pixi.js 渲染与 Blob 导出,注意 SwiftShader 启动参数与小尺寸固件带来的提速;
- 相关真实用例:compositeLayout.test.ts、tutorialHelpTranslations.test.ts、videoExporter.browser.test.ts、gifExporter.browser.test.ts、recordingStream.test.ts;
- 固件资源位于 tests/fixtures/,脚本入口见 package.json 中的
test、test:watch、test:browser、test:browser:install。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00