单元测试:给逻辑上保险
本教程共 45 篇 · 第 36 篇 · 更新于 2026-08-13 · 约 3 分钟阅读
本节目标:了解单元测试在扩展项目里的价值,学会用 Vitest 与 WxtVitest 插件搭建测试环境,掌握 fake-browser 与 Mock 的用法,再看一个 i18n 词典一致性测试的完整案例。
扩展里最值得测的不是界面,而是逻辑。翻译函数对不对、存储读写对不对、消息校验严不严——这些逻辑散落在弹窗、后台、内容脚本里,靠手点浏览器很难覆盖全。单元测试(unit testing)把这些函数从浏览器里拿出来,在 Node 环境里快速验证。WXT 对 Vitest 提供一等支持,加一个插件就能跑,几乎不用额外配置。
装插件,一行配置
先把 vitest 装进 devDependencies,然后在项目根目录创建 vitest.config.ts:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { WxtVitest } from 'wxt/testing/vitest-plugin';
export default defineConfig({
plugins: [WxtVitest()],
});
WxtVitest 插件替你做了五件事:
- 用 @webext-core/fake-browser 把浏览器 API(browser 对象)换成内存实现;
- 合并 wxt.config.ts 里的 Vite 配置与插件;
- 配置自动导入(项目启用了的话);
- 设置 WXT 的全局变量:import.meta.env.BROWSER、MANIFEST_VERSION、IS_CHROME 等;
- 配置 @/、@@/ 等路径别名,让 import 能正常解析。
fake-browser 是内存实现,所以 wxt/utils/storage 这类封装在测试里直接可用,行为与真实扩展一致,不用手写 mock。
测试怎么写:fake-browser 示例
官方文档的例子是测一个「是否登录」的函数。它读 storage 里的账号信息,我们用 fake-browser 直接准备数据:
import { describe, it, expect, beforeEach } from 'vitest';
import { fakeBrowser } from 'wxt/testing/fake-browser';
const accountStorage = storage.defineItem<Account>('local:account');
async function isLoggedIn(): Promise<boolean> {
return (await accountStorage.getValue()) != null;
}
describe('isLoggedIn', () => {
beforeEach(() => {
fakeBrowser.reset(); // 每个用例前重置内存状态
});
it('存储里有账号时返回 true', async () => {
await accountStorage.setValue({ username: '...', preferences: {} });
expect(await isLoggedIn()).toBe(true);
});
it('没有账号时返回 false', async () => {
await accountStorage.deleteValue();
expect(await isLoggedIn()).toBe(false);
});
});
两个用例互相独立,靠的是 beforeEach 里的 fakeBrowser.reset()。它把内存里的存储、消息、权限全部还原,避免用例之间互相污染。
要 Mock 的是真实路径,不是 #imports
Mock WXT 工具函数时有个坑。你写代码时 import 的是 #imports,但 WXT 会在预处理阶段把它替换成多个真实路径。比如 createShadowRootUi 来自 wxt/utils/content-script-ui/shadow-root,injectScript 来自 wxt/utils/inject-script。所以 vi.mock 要写真实路径:
vi.mock('wxt/utils/inject-script', () => ({
injectScript: vi.fn(),
}));
想查某个符号的真实路径,打开 .wxt/types/imports-module.d.ts。文件不存在就先跑 wxt prepare(如 §29 所述)。
案例:i18n 词典一致性测试
多语言扩展最怕中英文词典的 key 对不上。漏一个 key,界面就少一段翻译。mkext 项目用三层防线锁死这个问题(原项目用 Bun 测试,下面是按 Vitest 改写的示例):
- 类型层:词典对象声明为 Record<Locale, Messages>,缺 key 在 typecheck 阶段直接报错;
- 一致性层:断言中英文词典的 key 集合完全一致、每个词条非空;
- 死词条层:扫描源码里所有 t(”…”) / getMessage(”…”) 调用,收集到的 key 必须等于词典 key 全集——没人用的词条也会被揪出来。
import { describe, expect, it } from 'vitest';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import enMessages from '@/locales/en.json';
import zhMessages from '@/locales/zh_CN.json';
const sourceRoot = fileURLToPath(new URL('../..', import.meta.url));
const messageCall = /(?<![\w.])(?:t|getMessage)\("([A-Za-z0-9_]+)"\)/g;
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const full = `${dir}/${name}`;
return statSync(full).isDirectory() ? walk(full) : [full];
});
}
function getUsedMessageKeys() {
const keys = new Set<string>();
for (const file of walk(sourceRoot)) {
if (!/\.(ts|tsx|html)$/.test(file)) continue;
const source = readFileSync(file, 'utf-8');
for (const match of source.matchAll(messageCall)) {
if (match[1]) keys.add(match[1]);
}
}
return [...keys].sort();
}
describe('locale dictionaries', () => {
it('中英文词典 key 完全一致', () => {
expect(Object.keys(zhMessages).sort()).toEqual(Object.keys(enMessages).sort());
});
it('所有词条 message 非空', () => {
for (const dict of [enMessages, zhMessages]) {
for (const value of Object.values(dict)) {
expect(value.message.trim()).not.toBe('');
}
}
});
it('词典里没有未被界面使用的死词条', () => {
expect(getUsedMessageKeys()).toEqual(Object.keys(enMessages).sort());
});
});
从此改词典时,漏加、错删、写空都会立刻在测试里现形。
想换测试框架?
可以,但麻烦。不用 Vitest 就得自己处理:关掉自动导入、配置别名、手动 mock 浏览器 API、搭测试环境。官方把参考实现放在 wxt 仓库的 wxt-vitest-plugin.ts 里,照抄也行。
mock 的边界:WXT 的 API 用 fake-browser 内存实现,第三方库用 vi.mock;别 mock 你自己项目的模块,保持测试贴近真实。
小结
- 单元测试不启动浏览器,跑得快、定位准,是纯逻辑的保险。
- 它证明不了权限、注入时机这些浏览器行为,那些要交给端到端测试(§37)。