首页
/ Svelte 5 测试实践指南:Vitest 单测与组件测试、Storybook 交互测试与 Playwright E2E

Svelte 5 测试实践指南:Vitest 单测与组件测试、Storybook 交互测试与 Playwright E2E

2026-09-06 13:07:39作者:丁柯新Fawn

本文基于 Svelte 官方文档 Testing 整理并展开,覆盖 Svelte 项目的三层测试体系:使用 Vitest 编写单元测试与组件测试(包括在测试文件中直接使用 runes 的关键技巧)、使用 Storybook 做浏览器环境下的组件交互测试、使用 Playwright 做端到端测试,并结合 Svelte 仓库自身的 vitest.config.jspackage.json 源码配置,解释文档中每项配置背后的真实原因,读完即可在自己项目中搭起一套完整的 Svelte 测试方案。

整体思路:Svelte 对测试框架保持中立

Svelte 官方对测试框架持开放态度:单元测试、集成测试、端到端测试都可以自由选择实现方案,官方文档中给出的示例组合是 Vitest(单元/组件测试)、Storybook(浏览器中的组件测试)、Playwright(端到端测试),同时兼容 Jasmine、Cypress、NightwatchJS 等替代方案。

这套分层策略的核心价值在于:

  • 单元测试:验证 .svelte.js/.svelte.ts 中导出的孤立逻辑,速度最快;
  • 组件测试:在真实或模拟的 DOM 中渲染组件,模拟交互并断言;
  • E2E 测试:以用户视角走完整应用流程,测试代码完全不感知 Svelte 框架本身。

下面逐层展开,并给出与 Svelte 仓库自身测试基建相互印证的实现细节。

用 Vitest 搭建单元与组件测试环境

如果你使用 Vite(包括通过 SvelteKit),官方推荐 Vitest。可以用 Svelte CLI 在项目创建时或之后一键配置 Vitest;下面是手动配置的完整步骤。

安装与 vite.config.js 配置

第一步,安装 Vitest:

npm install -D vitest

第二步,调整 vite.config.js

// file: vite.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
	// ...
	// Tell Vitest to use the `browser` entry points in `package.json` files, even though it's running in Node
	resolve: process.env.VITEST
		? {
				conditions: ['browser']
			}
		: undefined
});

注意:如果加载全部包的浏览器版本不合适(例如你同时要测试后端库),可能需要改用 alias 配置来按需指认入口。

为什么要加 conditions: ['browser'] 这一点在 Svelte 仓库自身的 packages/svelte/package.json 中可以得到直接印证。Svelte 包的 exports 字段对主入口按条件区分了浏览器与服务端两份实现:

".": {
  "types": "./types/index.d.ts",
  "worker": "./src/index-server.js",
  "browser": "./src/index-client.js",
  "default": "./src/index-server.js"
}

也就是说:当 Node 环境(Vitest 默认运行环境)解析 svelte 时,default 条件会命中 src/index-server.js(服务端渲染实现);而组件测试需要的是操作 DOM 的客户端实现 src/index-client.jsresolve.conditions: ['browser'] 正是告诉 Vitest「虽然是 Node 在跑,但请优先使用 browser 入口」。

Svelte 仓库的根 vitest.config.js 是这一思路的更精细版本:由于要在同一套测试里同时跑服务端与客户端代码,它没有简单加 browser 条件,而是用自定义 customResolver 按调用方路径动态选择入口——

// vitest.config.js(Svelte 仓库自身配置,节选)
alias: [
	{
		find: /^svelte\/?/,
		customResolver: (id, importer) => {
			const exported = pkg.exports[id === 'undefined' ? '.' : id.replace('undefined', './')];
			if (!exported) return;

			// When running the server version of the Svelte files,
			// we also want to use the server export of the Svelte package
			return path.resolve(
				'packages/svelte',
				importer?.includes('_output/server')
					? exported.default
					: exported.browser ?? exported.default
			);
		}
	}
]

此外该配置还包含一些对大型测试工程有参考价值的设置:testTimeout: 30_000(因为部分 dev 模式测试会触发 effect_update_depth_exceeded 保护,单次 flush 会产生上千个 Error 对象做栈追踪,默认 5 秒不够用)、coverage.provider: 'v8' 覆盖采集、以及通过 include/exclude 精确圈定测试入口(packages/svelte/**/*.test.tspackages/svelte/tests/*/test.ts 等)。

单元测试:直接测试 .svelte.js 中的逻辑

配置完成后,即可对 .js/.ts 文件中的代码写单元测试。官方示例是一个 multiplier 状态封装:

被测逻辑(multiplier.svelte.ts):

export function multiplier(initial: number, k: number) {
	let count = $state(initial);

	return {
		get value() {
			return count * k;
		},
		set: (c: number) => {
			count = c;
		}
	};
}

测试文件(multiplier.svelte.test.js):

import { flushSync } from 'svelte';
import { expect, test } from 'vitest';
import { multiplier } from './multiplier.svelte.js';

test('Multiplier', () => {
	let double = multiplier(0, 2);

	expect(double.value).toEqual(0);

	double.set(5);

	expect(double.value).toEqual(10);
});

等价的 JSDoc 类型版本(multiplier.svelte.js)同样可用,Svelte 编译与 Vite 处理对 TypeScript 和 JSDoc 标注一视同仁:

/**
 * @param {number} initial
 * @param {number} k
 */
export function multiplier(initial, k) {
	let count = $state(initial);

	return {
		get value() {
			return count * k;
		},
		/** @param {number} c */
		set: (c) => {
			count = c;
		}
	};
}

在测试文件中使用 runes

这是 Svelte 5 测试与普通 JS 单元测试最大的差异点:Vitest 会通过 Svelte 的 Vite 插件以与源码文件相同的方式处理测试文件,因此只要测试文件名包含 .svelte,就可以在其中直接使用 runes——上面示例文件名写作 multiplier.svelte.test.js 正是这个原因。

例如被测逻辑不自己持有状态,而是接收一个取值函数,测试里就可以直接用 $state

// multiplier.svelte.test.js
import { flushSync } from 'svelte';
import { expect, test } from 'vitest';
import { multiplier } from './multiplier.svelte.js';

test('Multiplier', () => {
	let count = $state(0);
	let double = multiplier(() => count, 2);

	expect(double.value).toEqual(0);

	count = 5;

	expect(double.value).toEqual(10);
});

对应的被测代码:

/**
 * @param {() => number} getCount
 * @param {number} k
 */
export function multiplier(getCount, k) {
	return {
		get value() {
			return getCount() * k;
		}
	};
}

涉及 $effect 时必须用 $effect.root 包裹:effects 不会在普通函数调用上下文里工作,需要根节点挂载。官方示例用 logger 演示了完整套路——$effect.root 建立根、flushSync 把本来在微任务后才执行的 effects 同步冲刷掉、最后调用返回的 cleanup 函数清理:

// logger.svelte.test.js
import { flushSync } from 'svelte';
import { expect, test } from 'vitest';
import { logger } from './logger.svelte.js';

test('Effect', () => {
	const cleanup = $effect.root(() => {
		let count = $state(0);

		// logger uses an $effect to log updates of its input
		let log = logger(() => count);

		// effects normally run after a microtask,
		// use flushSync to execute all pending effects synchronously
		flushSync();
		expect(log).toEqual([0]);

		count = 1;
		flushSync();

		expect(log).toEqual([0, 1]);
	});

	cleanup();
});

被测的 logger

/**
 * @param {() => any} getValue
 */
export function logger(getValue) {
	/** @type {any[]} */
	let log = [];

	$effect(() => {
		log.push(getValue());
	});

	return log;
}

源码层面的对应关系

  • $effect.root 的内部实现在 packages/svelte/src/internal/client/reactivity/effects.jseffect_rootBatch.ensure() 确保当前批次存在,再创建带 ROOT_EFFECT 标志的 effect 执行 fn,并返回一个销毁该 effect 的清理函数——这正是测试中 cleanup() 的作用,防止跨测试串扰(批处理层还提供了 clear() 用于在测试间清空全部批次)。
  • flushSync 的实现在 packages/svelte/src/internal/client/reactivity/batch.js:它循环执行 flush_tasks() 并冲刷 current_batch,直到不再有待处理的批次,从而保证调用返回时所有 effects/DOM 更新已同步完成,测试才能在同一次调用中写出确定性的断言。

组件测试:jsdom + mount

组件测试可以在真实或模拟的浏览器中渲染组件、模拟行为、做断言,而无需启动整个应用。

官方建议:写组件测试前先想清楚——你是真的要测组件本身,还是组件内部的逻辑?如果是后者,优先把逻辑抽出来单独测,省去组件测试的开销。

第一步,安装 jsdom(模拟 DOM API 的库):

npm install -D jsdom

第二步,在 vite.config.js 中配置 DOM 环境(注意同时保留上面的 browser conditions 设置):

// file: vite.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
	plugins: [
		/* ... */
	],
	test: {
		// If you are testing components client-side, you need to set up a DOM environment.
		// If not all your files should have this environment, you can use a
		// `// @vitest-environment jsdom` comment at the top of the test files instead.
		environment: 'jsdom'
	},
	// Tell Vitest to use the `browser` entry points in `package.json` files, even though it's running in Node
	resolve: process.env.VITEST
		? {
				conditions: ['browser']
			}
		: undefined
});

如果只有部分测试文件需要 jsdom 环境,可以不在全局配置 environment,改为在对应测试文件顶部写 // @vitest-environment jsdom 注释,粒度更细。

第三步,用 Svelte 的 mount API 实例化组件并断言。完整可运行示例:

// component.test.js
import { flushSync, mount, unmount } from 'svelte';
import { expect, test } from 'vitest';
import Component from './Component.svelte';

test('Component', () => {
	// Instantiate the component using Svelte's `mount` API
	const component = mount(Component, {
		target: document.body, // `document` exists because of jsdom
		props: { initial: 0 }
	});

	expect(document.body.innerHTML).toBe('<button>0</button>');

	// Click the button, then flush the changes so you can synchronously write expectations
	document.body.querySelector('button')?.click();
	flushSync();

	expect(document.body.innerHTML).toBe('<button>1</button>');

	// Remove the component from the DOM
	unmount(component);
});

这里的 mount/unmount/hydrate 均从 svelte 主入口导出,最终指向 packages/svelte/src/internal/client/render.js 中的 mount(第 66 行)等函数;这也是 Svelte 5 与 Svelte 4 的显著区别——组件不再是类,而是用 mount(component, options) 这样的函数式 API 挂载(packages/svelte/src/index-client.js 中可看到 export { hydrate, mount, unmount } from './internal/client/render.js')。

这种写法直白但偏底层且脆弱——组件内部结构一变,document.body.innerHTML 断言就容易碎。此时推荐改用 Testing Library(如 @testing-library/svelte),上面的测试可以重写为:

// component.test.js
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { expect, test } from 'vitest';
import Component from './Component.svelte';

test('Component', async () => {
	const user = userEvent.setup();
	render(Component);

	const button = screen.getByRole('button');
	expect(button).toHaveTextContent(0);

	await user.click(button);
	expect(button).toHaveTextContent(1);
});

Testing Library 按可访问性角色(getByRole)与文本定位元素,对组件内部实现细节不敏感。对于涉及双向绑定、context 或 snippet props 的组件测试,官方建议为具体测试单独创建一个包装组件,然后与包装组件交互,而不是直接硬造这些依赖。

用 Storybook 做浏览器中的组件测试

Storybook 既是 UI 组件开发与文档工具,也能做组件测试:它跑在 Vitest 的 browser mode 下,把组件渲染进真实浏览器,提供最贴近生产的环境。

接入方式:在项目中通过 npx sv add storybook 安装 Svelte 的 Storybook 集成,并选择带测试能力的推荐配置;若项目已有 Storybook,直接按官方测试文档推进即可。

核心是用 play 函数 模拟用户行为并做断言(借助 Testing Library 与 Vitest API)。示例为一个 LoginForm 的两个 story:一个渲染空表单,一个模拟用户填表提交:

<!-- file: LoginForm.stories.svelte -->
<script module>
	import { defineMeta } from '@storybook/addon-svelte-csf';
	import { expect, fn } from 'storybook/test';

	import LoginForm from './LoginForm.svelte';

	const { Story } = defineMeta({
		component: LoginForm,
		args: {
			// Pass a mock function to the `onSubmit` prop
			onSubmit: fn(),
		}
	});
</script>

<Story name="Empty Form" />

<Story
	name="Filled Form"
	play={async ({ args, canvas, userEvent }) => {
		// Simulate a user filling out the form
		await userEvent.type(canvas.getByTestId('email'), 'email@provider.com');
		await userEvent.type(canvas.getByTestId('password'), 'a-random-password');
		await userEvent.click(canvas.getByRole('button'));

		// Run assertions
		await expect(args.onSubmit).toHaveBeenCalledTimes(1);
		await expect(canvas.getByText('You’re in!')).toBeInTheDocument();
	}}
/>

要点:通过 argsonSubmit 传入 mock 函数(fn()),play 函数里既能用 canvas 定位并操作元素,又能用 expect(args.onSubmit).toHaveBeenCalledTimes(1) 这类断言验证回调行为,实现「交互 + 行为」一体的组件级验收测试。

用 Playwright 做端到端测试

E2E 测试以用户视角验证完整应用。官方示例以 Playwright 为例,也可换成 Cypress、NightwatchJS。

接入方式有两种:Svelte CLI 可以在创建项目时或之后一键配置 Playwright;或者直接用 npm init playwright 初始化。若用 IDE 开发,还可安装 Playwright 的 VS Code 扩展,在编辑器内直接执行测试。

如果通过 npm init playwright 初始化或项目不使用 Vite,可能需要调整 Playwright 配置,告诉它跑测试前先做什么——关键是用 webServer 在指定端口启动应用:

// file: playwright.config.js
const config = {
	webServer: {
		command: 'npm run build && npm run preview',
		port: 4173
	},
	testDir: 'tests',
	testMatch: /(.+\.)?(test|spec)\.[jt]s/
};

export default config;

配置项说明:

  • webServer.command:测试执行前的启动命令,这里选择先构建再预览(preview 端口为 4173),验证的是产物而非开发服务器;
  • webServer.port:与 commandpreview 的端口保持一致;
  • testDir:测试文件目录;
  • testMatch:匹配 *.test.js/ts*.spec.js/ts 文件。

E2E 测试完全不了解 Svelte 的存在,只与 DOM 打交道:

// file: tests/hello-world.spec.js
import { expect, test } from '@playwright/test';

test('home page has expected h1', async ({ page }) => {
	await page.goto('/');
	await expect(page.locator('h1')).toBeVisible();
});

Svelte 仓库自身如何验证这些流程

Svelte 仓库本身就完整示范了本文的全部工具链,可以作为参照:

  • Vitest 版本与 E2E 依赖packages/svelte/package.jsondevDependencies 中同时包含 vitest^4.1.7)与 @playwright/test^1.62.0),与文档推荐一致;
  • 测试组织方式:测试入口集中在 packages/svelte/tests/ 下的多个套件(runtime-runeshydrationserver-side-renderingcompiler-errors 等,各套件一个 test.ts),由根 vitest.config.jsinclude 统一收集,packages/svelte/**/*.test.ts 则覆盖源码旁的单元测试(如 proxy.test.tsclone.test.ts);
  • browser 条件的真实工程化:如前文所述,Svelte 仓库没有简单全局开启 browser 条件,而是用 customResolver 按调用者区分客户端/服务端入口——如果你的项目也需要同时测试前端组件与服务端逻辑,这个 vitest.config.js 值得直接参考。

小结

测试层级 推荐工具 关键点
单元测试 Vitest 测试 .svelte.js 导出逻辑;文件名含 .svelte 才能在测试中用 runes
含 effects 的逻辑 Vitest + $effect.root flushSync 同步冲刷,结束后调用返回的 cleanup
组件测试 Vitest + jsdom / Testing Library vite.config.jsenvironment: 'jsdom'resolve.conditions: ['browser'],用 mount/unmount 挂载卸载
组件交互验收 Storybook + Vitest browser mode play 函数模拟用户行为并断言
E2E Playwright webServer 在固定端口启动应用,测试代码只与 DOM 交互

遵循「逻辑下沉可单测、组件用 mount 或 Testing Library、全流程交给 Playwright」的分层原则,即可为 Svelte 5 项目建立起覆盖全面、且每一层都有官方文档与仓库自身实践背书的测试体系。

登录后查看全文
热门项目推荐
相关项目推荐