Skip to Content

Vitest

第一个测试

配置

pnpm add -D \ jsdom \ vitest \ @testing-library/dom \ @testing-library/jest-dom \ @testing-library/react \ @testing-library/user-event \ @vitest/coverage-v8 \ @vitest/ui
vitest.config.ts
// 配置合并 import { defineConfig, searchForWorkspaceRoot } from "vite"; import { defineConfig as defineTestConfig, mergeConfig } from "vitest/config"; // https://vitest.dev/guide/#configuring-vitest import react from "@vitejs/plugin-react"; import { fileURLToPath } from "url"; // https://vite.dev/config/ const viteConfig = defineConfig({ plugins: [react()], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)), }, }, server: { fs: { // for Rush.js allow: [ searchForWorkspaceRoot(process.cwd()), "../../common/temp/node_modules", ], }, }, }); export default mergeConfig( viteConfig, defineTestConfig({ test: { // 默认 node, 通过 jsdom 模拟浏览器环境 environment: "jsdom", }, }), );

react act()

使用 act() 测试组件渲染

import { expect, test } from "vitest"; import App from "@/App"; import { act } from "react"; import { createRoot } from "react-dom/client"; test("react act", () => { const container = document.createElement("div"); document.body.appendChild(container); // 使用 act() 包裹组件渲染 act(() => { createRoot(container).render(<App />); }); const heading = container.querySelector("h1"); const button = container.querySelector("button"); act(() => { // 使用 act() 包裹事件派发 button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(heading?.textContent).toBe("role: heading"); expect(button?.textContent).toBe("count is 1"); });

[!warning] Pitfall 陷阱 事件派发只有在 container 被添加到文档时才有效, 即 document.body.appendChild(container);

测试渲染

src/components/toggle-purple.tsx
function TogglePurple({ isPurple, setIsPurple, }: { isPurple: boolean; setIsPurple: (isPurple: boolean) => void; }) { return ( <label> Purple <input type="checkbox" checked={isPurple} onChange={() => setIsPurple(!isPurple)} /> </label> ); } export default TogglePurple;

配置

import { describe, test, it } from "vitest";
  • describe 测试分组
  • it 等价于 test

globals 全局 API

如果 vitest 配置了 globals 全局 API, 则 @testing-library/react 会使用全局的 afterEach() 自动调用 cleanup(), 卸载通过 render() 挂载的 react 组件树

vitest.config.ts
import { defineConfig } from "vitest/config"; export default defineConfig({ test: { // 默认 node, 通过 jsdom 模拟浏览器环境 environment: "jsdom", // 不需要显式导入 describe, test, it 等 globals: true, // 副作用导入 @testing-library/jest-dom/vitest setupFiles: ["./tests/setup-jsdom.ts"], }, });

优化测试

// tests/components/toggle-purple.test.tsx import { render, screen } from "@testing-library/react"; import TogglePurple from "@/components/toggle-purple"; describe("TogglePurple", () => { let isPurple = false; const setIsPurple = (newIsPurple: boolean) => { isPurple = newIsPurple; }; beforeEach(() => { render(<TogglePurple isPurple={isPurple} setIsPurple={setIsPurple} />); }); it("should render the checkbox and the checkbox should not be checked by default", () => { const checkboxElement = screen.getByRole("checkbox"); expect(checkboxElement).toBeInTheDocument(); expect(checkboxElement).not.toBeChecked(); // 自动调用 testing-library 的 cleanup 函数, 卸载组件并销毁容器 }); it("should render the label with purple text", () => { screen.debug(); const labelElement = screen.getByText(/purple/i); expect(labelElement).toBeInTheDocument(); }); });

测试用户交互

错误示例
import { render, screen } from "@testing-library/react"; import TogglePurple from "@/components/toggle-purple"; import userEvent from "@testing-library/user-event"; import { useState } from "react"; describe("TogglePurple", () => { // 无效的 hook 调用, hook 只能在函数组件的内部调用 const [isPurple, setIsPurple] = useState(false); beforeEach(() => { render(<TogglePurple isPurple={isPurple} setIsPurple={setIsPurple} />); }); describe("user interaction test", () => { it("should be checked after user click", async () => { const checkboxElement = screen.getByRole("checkbox"); const user = userEvent.setup(); await user.click(checkboxElement); expect(checkboxElement).toBeChecked(); }); }); });

遍历测试

Vitest UI

pnpm add @vitest/ui -D pnpm exec vitest --ui
import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useState } from "react"; import SelectColor from "@/components/select-color"; function SelectColor4mock() { const [textColor, setTextColor] = useState(""); return <SelectColor textColor={textColor} setTextColor={setTextColor} />; } describe("SelectColor", () => { beforeEach(() => { render(<SelectColor4mock />); }); describe("static render test", () => { it("should render the select element", () => { // https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/select#technical_summary const selectElement = screen.getByRole("combobox"); expect(selectElement).toBeInTheDocument(); }); it("should render the label element", () => { // https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/select#technical_summary const labelElement = screen.getByText(/text color/i); expect(labelElement).toBeInTheDocument(); }); }); describe("interaction test", () => { it.each([ { optionValue: "", label: "white" }, { optionValue: "text-blue", label: "blue" }, { optionValue: "text-green", label: "green" }, ])( "should display the $label after user click the $label option", async ({ optionValue }) => { const selectElement = screen.getByRole("combobox"); // screen.debug(); const user = userEvent.setup(); await user.click(selectElement); // https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/option#technical_summary const optionElements = screen.getAllByRole("option"); expect(optionElements).toHaveLength(3); // <option value="text-green">Green</option> // name: Green, value: text-green await user.selectOptions(selectElement, optionValue); expect(selectElement).toHaveValue(optionValue); }, ); }); });

测试覆盖率

import { defineConfig } from "vitest/config"; export default defineConfig({ test: { // 默认 node, 通过 jsdom 模拟浏览器环境 environment: "jsdom", // 不需要显式导入 describe, test, it 等 globals: true, // 副作用导入 @testing-library/jest-dom/vitest setupFiles: ["./tests/setup-jsdom.ts"], // 测试覆盖率 coverage: { enabled: true, provider: "v8", include: ["src/**/*.{ts,tsx}"], }, }, });

测试键盘输入

import { cleanup, render, screen } from "@testing-library/react"; import CircleProperty from "@/components/circle-property"; import userEvent from "@testing-library/user-event"; import { useState, type PropsWithChildren } from "react"; describe("CircleProperty", () => { function CircleProperty4mock({ children = "demo" }: PropsWithChildren) { const [property, setProperty] = useState(0); return ( <CircleProperty property={property} setProperty={setProperty}> {children} </CircleProperty> ); } beforeEach(() => { render(<CircleProperty4mock />); }); describe("static render test", () => { it("should render the input element", () => { // https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#technical_summary const inputElement = screen.getByRole("spinbutton"); expect(inputElement).toBeInTheDocument(); }); it("should render the label element", () => { // Unmount the `<CircleProperty4mock>` component by `beforeEach()` cleanup(); const labelText = "Vitest is Awesome"; render(<CircleProperty4mock>{labelText}</CircleProperty4mock>); screen.debug(); const labelElement = screen.getByText(labelText); expect(labelElement).toBeInTheDocument(); }); }); describe("interaction test", () => { it("should display the correct value after user type", async () => { const inputElement = screen.getByRole("spinbutton"); const user = userEvent.setup(); const inputNumber = 30; await user // MUST `click()` before `keyboard()` .click(inputElement) .then(() => user.clear(inputElement)) .then(() => user.keyboard(inputNumber.toString())); expect(inputElement).toHaveValue(inputNumber); }); }); });

Vitest 浏览器模式

Why

@/components/button.tsx
import { useState, type ComponentProps, type MouseEvent } from "react"; import "./button.css"; function Button(props: ComponentProps<"button">) { const { onClick, children, ...rest } = props; const [content, setContent] = useState("Click me"); const [classNames, setClassNames] = useState("btn"); const handleClick = (e: MouseEvent<HTMLButtonElement>) => { onClick?.(e); setContent((content) => (content === "Click me" ? "Active" : "Click me")); setClassNames((classNames) => classNames === "btn" ? "btn active" : "btn", ); }; return ( <button {...rest} onClick={handleClick} className={classNames}> {children ?? content} </button> ); } export default Button;

配置

pnpm add -D \ jsdom \ playwright \ vitest \ vitest-browser-react \ @vitest/browser-playwright \ @vitest/coverage-v8 \ @vitest/ui
vite.config.ts
/// <reference types="vitest/config" /> // https://www.typescriptlang.org/docs/handbook/triple-slash-directives.html import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { playwright } from "@vitest/browser-playwright"; import { fileURLToPath } from "url"; // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)), }, }, test: { globals: true, // 浏览器模式 browser: { enabled: true, provider: playwright(), // https://vitest.dev/config/browser/playwright instances: [{ browser: "chromium" }], }, }, });

无障碍

src/components/note-item.tsx
<button onClick={(e) => { e.stopPropagation(); deleteNote(note.id); }} className="btn btn-square btn-error lg:btn-lg" aria-label="delete-button" > {isDeleting ? ( <span className="loading loading-spinner" /> ) : ( <TrashIcon size={24} weight="thin" /> )} </button>

vi utility

  • vi.mock()
  • vi.mocked()
  • vi.waitFor()
  • vi.importActual()
mock 接口
import { getNotes } from "@/api/note"; import NoteList from "@/components/note-list"; import ModalContextProvider from "@/context/modal-context-provider"; import type { Note } from "@/schema"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render } from "vitest-browser-react"; // vi.mock 会被提升到文件的最顶层, 在所有 import 语句前执行 vi.mock("@/api/note", { spy: true }); describe("NoteList", () => { const queryClient = new QueryClient({ defaultOptions: { queries: { // 请求失败时, 不重试 retry: false, }, }, }); const mockedNotes: Note[] = Array.from({ length: 3 }).map((_, index) => ({ id: index + 1, title: `Note ${index + 1}`, content: `Content for note ${index + 1}`, })); const renderComponent = async () => { const renderResult = await render( <QueryClientProvider client={queryClient}> <ModalContextProvider> <NoteList /> </ModalContextProvider> </QueryClientProvider>, ); return renderResult; }; describe("static render test", () => { it("should render note list while request ok", async () => { // Arrange // mock 接口返回的数据, 先 mock 数据, 后渲染 vi.mocked(getNotes).mockResolvedValue(mockedNotes); const renderResult = await renderComponent(); const { getByRole } = renderResult; // 等待 (虚拟滚动) 列表渲染完成 await vi.waitFor(() => { const mainLocator = getByRole("main"); expect(mainLocator).toBeInTheDocument(); }); // Act const noteItems = getByRole("listitem").elements(); // Assert expect(getNotes).toHaveBeenCalled(); // { spy: true } expect(getNotes).toHaveBeenCalledTimes(1); expect(getNotes).toHaveResolvedWith(mockedNotes); expect(noteItems).toHaveLength(mockedNotes.length); }); it("should render note list skeleton while request pending", async () => { // Arrange // mock `getNotes` 的实现 vi.mocked(getNotes).mockImplementation(() => { return new Promise(() => {}); }); const { getByRole } = await renderComponent(); // Act // 匹配 role="progressbar" // div 标签没有语义, 没有 Aria role // 手动指定 <div role="progressbar" /> const skeletonLocator = getByRole("progressbar"); // Assert await expect.element(skeletonLocator).toBeInTheDocument(); }); it("should render note list error while request error", async () => { const errorMessage = "Mocked error message"; vi.mocked(getNotes).mockRejectedValue(new Error(errorMessage)); const { getByRole } = await renderComponent(); // Act // 匹配 role="alert" // div 标签没有语义, 没有 Aria role // 手动指定 <div role="alert" /> const errorAlertLocator = getByRole("alert"); // Assert await expect.element(errorAlertLocator).toBeInTheDocument(); await expect.element(errorAlertLocator).toHaveTextContent(errorMessage); }); }); });

MSW

配置

tests/vitest.setup.ts
// 保留样式 import "@/index.css"; import { http, HttpResponse } from "msw"; // For node import { setupServer } from "msw/node"; // For browser import { setupWorker } from "msw/browser"; const API_URL = import.meta.env.VITE_API_URL; const handlers = [ // Initial request handlers http.get(API_URL, () => { // 必须 return return HttpResponse.json( Array.from({ length: 2027 }).map((_, index) => ({ id: index + 1, title: `Note ${index + 1}`, content: `Content for note ${index + 1}`, })), ); // Mocked data }), ]; // For node const server = setupServer(...handlers); beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); // For browser const worker = setupWorker(...handlers); beforeAll(() => worker.start()); afterEach(() => worker.resetHandlers()); afterAll(() => worker.stop());

使用 MSW mock 接口

import NoteList from "@/components/note-list"; import ModalContextProvider from "@/context/modal-context-provider"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render } from "vitest-browser-react"; import worker from "../mocks/worker"; import { delay, http, HttpResponse } from "msw"; describe("NoteList by MSW", () => { beforeAll(() => worker.start()); afterEach(() => worker.resetHandlers()); afterAll(() => worker.stop()); const queryClient = new QueryClient({ defaultOptions: { queries: { // 请求失败时, 不重试 retry: false, }, }, }); const renderComponent = async () => { const renderResult = await render( <QueryClientProvider client={queryClient}> <ModalContextProvider> <NoteList /> </ModalContextProvider> </QueryClientProvider>, ); return renderResult; }; describe("static render test", () => { it("should render note list while request ok", async () => { // Arrange const renderResult = await renderComponent(); const { getByRole } = renderResult; // 等待 (虚拟滚动) 列表渲染完成 await vi.waitFor(() => { const mainLocator = getByRole("main"); expect(mainLocator).toBeInTheDocument(); }); // Act const noteItems = getByRole("listitem").elements(); // Assert expect(noteItems.length).toBeGreaterThan(0); }); it("should render note list skeleton while request pending", async () => { worker.use( // Runtime request handlers (override initial request handlers) http.get(import.meta.env.VITE_API_URL, async () => { await delay(); return HttpResponse.json([]); }), ); const { getByRole } = await renderComponent(); // Act // 匹配 role="progressbar" const skeletonLocator = getByRole("progressbar"); // Assert await expect.element(skeletonLocator).toBeInTheDocument(); }); it("should render note list error while request error", async () => { worker.use( // Runtime request handlers (override initial request handlers) http.get(import.meta.env.VITE_API_URL, async () => { return HttpResponse.error(); }), ); const { getByRole } = await renderComponent(); // Act // 匹配 role="alert" const errorAlertLocator = getByRole("alert"); // Assert await expect.element(errorAlertLocator).toBeInTheDocument(); }); }); });

使用 @mswjs/data mock CRUD

# 使用 @mswjs/data mock CRUD # 使用 faker-js mock 数据 pnpm add @mswjs/data @faker-js/faker -D
import { checkNote, noteSchema } from "@/schema"; import { Collection } from "@msw/data"; import { http, HttpResponse } from "msw"; import { faker } from "@faker-js/faker"; const API_URL = import.meta.env.VITE_API_URL; // pnpm add @mswjs/data @faker-js/faker -D const notes = new Collection({ schema: noteSchema, // zod schema }); const createManyNotes = notes.createMany(10, (index) => ({ id: index + 1, title: faker.book.title(), content: faker.lorem.lines(2), })); export default [ // Initial request handlers http.get(API_URL, async () => { await createManyNotes; // 必须 return return HttpResponse.json(notes.all()); }), http.delete<{ id: string /** 必须是 string, 不能是 number */ }>( `${API_URL}/:id`, async ({ params }) => { const { id } = params; const deletedNote = notes.delete((query) => query.where({ id: Number.parseInt(id) }), ); return HttpResponse.json(deletedNote); }, ), http.post(API_URL, async ({ request }) => { const newPost = await request.clone().json(); const [newNote, ok] = checkNote(newPost); if (!ok) { return HttpResponse.json( { error: new Error("Bad request") }, { status: 400 }, ); } const createNote = await notes.create(newNote); return HttpResponse.json(createNote); }), ];
Last updated on