SolidJS 如何进行单元测试和集成测试?有哪些测试工具推荐?
SolidJS 的测试生态以 Vitest 为核心,搭配官方测试库 @solidjs/testing-library,可以覆盖从信号级单元测试到组件集成测试的完整链路。下面从环境搭建、单元测试、响应式测试、集成测试到最佳实践逐一展开。
环境搭建
安装测试依赖:
bashnpm install -D vitest @solidjs/testing-library @testing-library/jest-dom jsdom @testing-library/user-event
在 vite.config.ts 中配置 Vitest:
typescriptimport { defineConfig } from "vite"; import solidPlugin from "vite-plugin-solid"; export default defineConfig({ plugins: [solidPlugin()], test: { globals: true, environment: "jsdom", transformMode: { web: [/\.jsx?$/] }, setupFiles: "./test/setup.ts", }, });
创建 test/setup.ts 文件,注册 jest-dom 匹配器:
typescriptimport "@testing-library/jest-dom";
在 package.json 中添加脚本:
json{ "scripts": { "test": "vitest", "test:coverage": "vitest run --coverage" } }
单元测试:组件渲染与交互
组件测试的核心思路是渲染组件、查找元素、模拟交互、断言结果,与用户视角对齐而非测试实现细节。
typescriptimport { render, screen } from "@solidjs/testing-library"; import userEvent from "@testing-library/user-event"; import { describe, it, expect } from "vitest"; import Counter from "./Counter"; describe("Counter", () => { it("renders initial count", () => { render(() => <Counter />); expect(screen.getByText("Count: 0")).toBeInTheDocument(); }); it("increments count on button click", async () => { const user = userEvent.setup(); render(() => <Counter />); await user.click(screen.getByRole("button", { name: /increment/i })); expect(screen.getByText("Count: 1")).toBeInTheDocument(); }); });
这里用 userEvent 替代 fireEvent,因为它更接近真实用户操作——触发完整的事件链(mousedown、focus、mouseup、click),而非仅派发单个事件。
单元测试:响应式原语
SolidJS 的响应式系统脱离 DOM 也能独立测试,关键是用 createRoot 包裹以确保副作用在测试结束后自动清理。
typescriptimport { createSignal, createEffect } from "solid-js"; import { createRoot } from "solid-js"; import { describe, it, expect } from "vitest"; describe("Signal reactivity", () => { it("tracks signal changes and triggers effects", () => createRoot((dispose) => { const [count, setCount] = createSignal(0); const log: number[] = []; createEffect(() => { log.push(count()); }); expect(log).toEqual([0]); setCount(5); expect(log).toEqual([0, 5]); dispose(); })); });
如果测试异步 Effect,推荐使用 @solidjs/testing-library 提供的 testEffect:
typescriptimport { testEffect } from "@solidjs/testing-library"; import { createSignal } from "solid-js"; describe("async effect", () => { it("resolves after signal update", () => testEffect((done) => { const [val, setVal] = createSignal(1); createEffect(() => { if (val() === 2) done(); }); setVal(2); })); });
单元测试:Hook 与自定义原语
不需要 DOM 的自定义 Hook 或原语,用 renderHook 测试更轻量:
typescriptimport { renderHook } from "@solidjs/testing-library"; import { describe, it, expect } from "vitest"; import { createCounter } from "./createCounter"; describe("createCounter", () => { it("increments and decrements", () => { const { result } = renderHook(() => createCounter(0)); expect(result.count()).toBe(0); result.increment(); expect(result.count()).toBe(1); result.decrement(); expect(result.count()).toBe(0); }); });
集成测试:组件协作与路由
集成测试验证多个组件协作时的行为,典型场景包括表单提交、路由导航、数据获取等。
表单提交流程
typescriptimport { render, screen } from "@solidjs/testing-library"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi } from "vitest"; import LoginForm from "./LoginForm"; describe("LoginForm integration", () => { it("submits form with user input", async () => { const onSubmit = vi.fn(); const user = userEvent.setup(); render(() => <LoginForm onSubmit={onSubmit} />); await user.type(screen.getByLabelText("Email"), "test@example.com"); await user.type(screen.getByLabelText("Password"), "secret123"); await user.click(screen.getByRole("button", { name: /submit/i })); expect(onSubmit).toHaveBeenCalledWith({ email: "test@example.com", password: "secret123", }); }); });
路由导航
typescriptimport { render, screen } from "@solidjs/testing-library"; import { Router, Routes, Route } from "@solidjs/router"; import { describe, it, expect } from "vitest"; import Home from "./Home"; import About from "./About"; describe("Routing integration", () => { it("navigates between pages", async () => { render(() => ( <Router> <Routes> <Route path="/" component={Home} /> <Route path="/about" component={About} /> </Routes> </Router> )); expect(screen.getByText("Home Page")).toBeInTheDocument(); }); });
集成测试:异步数据获取
使用 createResource 的场景在集成测试中需要 Mock 数据源:
typescriptimport { render, screen } from "@solidjs/testing-library"; import { describe, it, expect, vi, beforeEach } from "vitest"; import UserProfile from "./UserProfile"; describe("UserProfile with async data", () => { beforeEach(() => { vi.restoreAllMocks(); }); it("shows loading state then user data", async () => { const fetchUser = vi.fn().mockResolvedValue({ name: "Alice" }); render(() => <UserProfile fetchUser={fetchUser} />); expect(screen.getByText(/loading/i)).toBeInTheDocument(); expect(await screen.findByText("Alice")).toBeInTheDocument(); }); });
findByText 内部使用了 waitFor,会在异步渲染完成后自动重试查找,比手动 waitFor 更简洁。
Mock 与 Stub 策略
Vitest 内置了完整的 Mock 能力,覆盖最常见的测试场景:
typescriptimport { vi } from "vitest"; // Mock 模块 vi.mock("./api", () => ({ fetchUser: vi.fn().mockResolvedValue({ name: "Alice" }), })); // Mock 定时器 vi.useFakeTimers(); // Spy 对象方法 const spy = vi.spyOn(console, "log").mockImplementation(() => {});
对于 SolidJS 特有的响应式依赖,优先通过 props 注入 Mock 数据,而非直接 mock 内部信号——这样测试更接近真实运行路径。
测试覆盖率配置
在 vite.config.ts 中启用覆盖率:
typescripttest: { coverage: { provider: "v8", reporter: ["text", "html"], include: ["src/**/*.{ts,tsx}"], exclude: ["src/**/*.test.{ts,tsx}"], }, },
运行 npm run test:coverage 后在 coverage/ 目录查看 HTML 报告。覆盖率是代码质量的参考指标,但不应追求 100%——核心业务逻辑的覆盖率比工具函数更重要。
最佳实践总结
- 测试用户行为而非实现细节:通过 getByRole、getByText 查找元素,而非 querySelector 或 data-testid(除非别无选择)
- 用 createRoot 包裹响应式测试:确保 Effect 等副作用在测试结束后自动销毁,避免内存泄漏和测试间干扰
- 优先 userEvent 而非 fireEvent:userEvent 模拟完整的用户交互链路,测试结果更可靠
- 异步断言用 findBy 而非 waitFor + getBy:findBy 自带轮询,代码更简洁
- Mock 外部依赖,保持组件测试纯净:网络请求、浏览器 API 等外部依赖统一 Mock,保证测试稳定可重复
- 集成测试关注组件协作边界:不必重复单元测试已覆盖的逻辑,重点验证数据在组件间的流转