《Svelte 测试入门:Vitest 与 Testing Library》
要点
Svelte 5 换了响应式内核(runes),测试生态跟着重排过一轮:@testing-library/svelte 5.x 完全按 Svelte 5 的 mount 语义重写,Vitest 成了默认搭配。这篇把 SvelteKit 工程里组件测试这一层搭起来:装依赖、写配置、给 runes 组件写第一条用例、事件触发、状态断言,最后划清哪些东西在这个环境里测不了。口径:Svelte 5.53.x、@testing-library/svelte 5.3.x、Vitest 4.0.x、jsdom 环境。
本文不做:Playwright E2E、Storybook、浏览器模式组件测试(vitest-browser-svelte,一句带过)、服务端 load 函数测试。最后这条为什么划掉,见”SvelteKit 的边界”一节。
先说清组件测试在整个盘子里赚什么:单元测试护住纯逻辑,E2E 护住关键路径,组件层夹在中间,护住”props 进、渲染出、事件回”这条接口契约。Svelte 5 的 runes 让状态逻辑可以整体抽离,组件层要护的东西比以前更薄,但也更值得——薄接口是最容易在重构中被无声改坏的地方,测试是唯一可靠的回归保险。
最小配置
一个能跑的 SvelteKit 工程上加四个依赖:
npm install -D vitest @testing-library/svelte @testing-library/jest-dom jsdom
配置文件里两个插件叠加是关键——sveltekit() 负责编译,svelteTesting() 负责把模块解析条件切到浏览器侧,否则编译产物里会混进服务端代码,jsdom 里跑不起来:
// vitest.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { svelteTesting } from '@testing-library/svelte/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit(), svelteTesting()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest-setup.ts']
}
});
setup 文件一行,作用是让每个用例结束后自动清理挂载的组件:
// vitest-setup.ts
import '@testing-library/svelte/vitest';
package.json 里补两条 script:”test”: “vitest run”、”test:watch”: “vitest”。run 一次性跑完退出,挂提交前;watch 常驻,写组件时开着,改动落盘几秒内出结果。验收方式:先写一个必过的占位用例(expect(1).toBe(1)),跑 npx vitest run,看到 Test Files 1 passed (1) 说明管道通了,再往下写真的。
第一条用例:runes 组件
被测组件就一个带 runes 的计数器:
<!-- src/lib/Counter.svelte -->
<script lang="ts">
let { step = 1 } = $props();
let count = $state(0);
</script>
<button onclick={() => (count += step)}>{count}</button>
测试文件。注意 render 的传参:Svelte 5 之后第一个参数直接传组件本体,props 放第二个参数——可以平铺传,也可以显式写成 { props: { … } } 的形式。Svelte 4 时代”组件包在对象里传”的老写法在 5.x 行不通了,网上过时教程一半死在这:
import { render, screen, fireEvent } from '@testing-library/svelte';
import Counter from '$lib/Counter.svelte';
test('初始渲染计数为 0', () => {
render(Counter, { props: { step: 2 } });
expect(screen.getByRole('button')).toHaveTextContent('0');
});
test('点击按 step 步进', async () => {
render(Counter, { props: { step: 2 } });
const btn = screen.getByRole('button');
await fireEvent.click(btn);
expect(btn).toHaveTextContent('2');
await fireEvent.click(btn);
expect(btn).toHaveTextContent('4');
});
预期输出:npx vitest run 打出 Test Files 1 passed (1)、Tests 2 passed (2)。绿了再往下。
事件触发的两个关键
第一,fireEvent 是异步包装:它分发事件后等 Svelte 把 DOM 更新完才 resolve,所以必须 await;await 返回后断言是安全的。React 那套 act() 包裹在这里不存在——Svelte 5 的更新走微任务批处理,库已经替你等了。
第二,fireEvent 与 userEvent 的取舍。官方建议优先 userEvent:它模拟完整的事件序列(聚焦、按下、键入逐个触发),行为更接近真人。我的默认规则是输入框键入一律 userEvent.type,因为 input 事件链只有它能完整触发;按钮点击这类单步动作 fireEvent 就够。两者都要 await。
import userEvent from '@testing-library/user-event';
test('输入框回显', async () => {
render(InputEcho);
await userEvent.type(screen.getByRole('textbox'), '买咖啡');
expect(screen.getByRole('textbox')).toHaveValue('买咖啡');
});
jsdom 的边界要知道:它没有真实布局引擎,userEvent 的 pointer 类交互(hover、拖拽)和依赖几何位置的断言不可靠,这类用例留给浏览器模式或 E2E,本文不展开。
runes 状态怎么断言
状态断言有三条路径,按优先级排:
- 断 DOM:状态变了渲染跟着变,断渲染结果是 Testing Library 的本意,也最贴近用户视角;
- 状态逻辑抽进 .svelte.ts 模块:$state、$derived 是编译期语法,普通单测直接跑,不需要组件;
- 回调断言:把事件回调设计成 props,传 vi.fn() 进去,交互后断言收到的参数。
第二条展开。购物车状态抽成模块:
// src/lib/cart.svelte.ts
export function createCart() {
let items = $state<string[]>([]);
return {
get items() {
return items;
},
get total() {
return items.length;
},
add(name: string) {
items.push(name);
}
};
}
测试就是普通单元测试,不用任何 Svelte 运行时设施:
import { createCart } from '$lib/cart.svelte';
test('add 后 total 递增', () => {
const cart = createCart();
expect(cart.total).toBe(0);
cart.add('咖啡');
expect(cart.total).toBe(1);
});
这条路径的边界要主动说:$effect 在没有 effect root 的纯函数测试里会报 orphan 错误(effects established without a root 之类字样)。所以纯状态模块里别放 $effect——想测副作用逻辑就留在组件里走 DOM 断言。这不是妥协,是把”状态”与”副作用”分层的正确姿势:业务逻辑进了 .svelte.ts,组件薄到只剩编排。
第三条回调断言,测的是”组件把什么交给了外部”:
import { render, screen, fireEvent } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
test('提交后回调收到输入值', async () => {
const onAdd = vi.fn();
render(AddTodo, { props: { onAdd } });
await userEvent.type(screen.getByRole('textbox'), '买牛奶');
await fireEvent.click(screen.getByRole('button', { name: '添加' }));
expect(onAdd).toHaveBeenCalledWith('买牛奶');
});
验收手法:把回调断言临时改成 toHaveBeenCalledWith(‘别的’),确认用例会红——回调确实收到了 ‘买牛奶’ 才算测到点子上。
给实战组件接上测试
拿一个真实的待办表单组件走一遍上面三条路径的组合。组件长这样:输入框加添加按钮,点添加后清空输入框、把值交给 onAdd 回调:
test('待办表单:键入、提交、清空、回调一次到位', async () => {
const onAdd = vi.fn();
render(TodoForm, { props: { onAdd } });
const input = screen.getByRole('textbox');
await userEvent.type(input, '买牛奶');
await fireEvent.click(screen.getByRole('button', { name: '添加' }));
expect(onAdd).toHaveBeenCalledExactlyOnceWith('买牛奶');
expect(input).toHaveValue('');
});
一条用例同时压住四个断言点:键入生效、事件触发、回调参数正确、提交后状态归位。改组件时这条用例红了,先怀疑自己改坏了契约,别急着改测试——十次里有九次是组件真的变了行为。查询优先级提一句:能用 getByRole 就别用 getByText——role 查询强迫你把可访问性做对,label 没写的按钮当场就查不到,这是测试在替用户说话。
SvelteKit 的边界
jsdom 环境里最容易翻车的一条:被测组件的 import 链上有 .server.ts 文件,或 $env/static/private。这些是服务端专用模块,进了 jsdom 直接报错。解法不是配一堆 alias 绕过去,是分层:
- 组件只吃 props 和客户端模块,数据获取留在 +page.svelte 与 load/remote 那一层;
- 页面组件里业务逻辑重的部分抽成 lib 下的普通模块或 .svelte.ts,测试落在那里;
- 页面级集成路径(load 到渲染到动作到刷新)交给 E2E,jsdom 不背这个锅。
$env 的细分也顺手交代:$env/static/private 不能进客户端,$env/dynamic 可以——后者是运行时注入的公开变量,组件里用它不违规。判断不了就看文件名:带 .server 后缀的,测试与组件两头都别碰。
svelteTesting() 插件做的事值得说透一句:它调整 resolve 条件让 Svelte 编译走浏览器产物。所以”组件树里不能有服务端依赖”不是工具的脾气,是边界本身——测试环境把这个约束执行得很诚实,你组件里偷偷 import 的服务端模块它第一个揪出来。
load 函数那层真要测,也有正路:把 load 的逻辑抽成普通函数、load 只做粘合,函数本身用 vitest 直接测(mock 掉事件对象与 fetch)。这条适合逻辑真的重的页面;逻辑轻的页面,别为测而测,E2E 一条冒烟就够了。
浏览器模式一句带过
jsdom 测不了的交互(hover、拖拽、真实布局),Vitest 的 browser 模式配 vitest-browser-svelte 可以补上——真浏览器里跑组件,断言 API 与 Testing Library 同源。代价是启动一个浏览器、CI 配置重一截。我的口径:九成用例 jsdom 够用,hover 拖拽类先降级为浏览器手动验收,等这类用例多到值得再上 browser 模式,别一上来就背整个浏览器矩阵。
常见坑
- render 传参用了 Svelte 4 老写法:组件本体直接传,props 放第二参,别再包一层对象;
- fireEvent 没 await:断言跑在更新前面,偶发时红时绿——CI 上最迷惑的一种红;
- setup 文件没配:用例间组件互串,上一个 render 的 DOM 还挂着,getByRole 报”找到多个元素”;
- 漏掉 svelteTesting():服务端产物进了 jsdom,报一堆 hydration、SSR 相关的怪错;
- jest-dom 断言没生效:toHaveTextContent 报 not a function,检查 setupFiles 路径写对没有;
- 想测组件内部状态:Svelte 5 没有 instance 导出这回事,断 DOM 或把状态抽成 .svelte.ts;
- vi.mock 的提升行为没吃透:mock 声明会被提升到文件顶、先于所有 import 执行——在用例内部动态引用未 mock 的模块会拿到真实现;
- 嵌套文本导致 getByText 报多个匹配:父元素经子元素也含目标文本时直接红,改用 getByRole 或传正则精确限定。
验收清单
- npx vitest run 全绿,至少含:一条初始渲染断言、一条 fireEvent 交互、一条 userEvent 输入、一条 .svelte.ts 状态单测、一条回调参数断言;
- 把任一断言临时改红再跑一遍,确认 runner 真的在跑(防空转);
- 测试文件 grep 不到 $env 与 .server 的 import;
- 同一用例连跑三次结果一致(没有用例间污染)。
往哪个工程上叠:实战篇待办应用做完的工程最顺——组件、状态模块都是现成的,照本文顺序把配置架起来补用例就行;整体学习顺序与前置条件见学习路线。
本文数据核对时间:2026-02-23:Svelte 5.53.x(5.53.3 于 2026-02-23 发布)+ @testing-library/svelte 5.3.x(5.3.1)+ Vitest 4.0.x(4.0.18);jsdom 环境,svelteTesting 插件来自 @testing-library/svelte/vite,自动清理来自 @testing-library/svelte/vitest。