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// 配置合并import { defineConfig, searchForWorkspaceRoot } from "vite";import { defineConfig as defineTestConfig, mergeConfig } from "vitest/config";
// https://vitest.dev/guide/#configuring-vitestimport 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)), }, },});
export default mergeConfig( viteConfig, defineTestConfig({ test: { // 默认 node, 通过 jsdom 模拟浏览器环境 environment: "jsdom", }, }),);import { useState } from "react";
function App() { const [count, setCount] = useState(0);
return ( <> <h1>role: heading</h1> <div className="counter"> <button onClick={() => setCount((count) => count + 1)}> count is {count} </button> </div> </> );}
export default App;import { expect, test } from "vitest";import { render, screen } from "@testing-library/react";import App from "@/App";import "@testing-library/jest-dom/vitest";
test("expect h1 element to be in the document", () => { render(<App />);
// h1: role=heading const headingElement = screen.getByRole("heading"); const buttonElement = screen.getByRole("button"); // 必须副作用导入 "@testing-library/jest-dom/vitest" 以使用 toBeInTheDocument() expect(headingElement).toBeInTheDocument(); expect(buttonElement).toBeInTheDocument();});react act()
Section titled “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");});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 { expect, test } from "vitest";import { render, screen } from "@testing-library/react";import TogglePurple from "@/components/toggle-purple";import "@testing-library/jest-dom/vitest";
// 避免重复渲染let isPurple = false;const setIsPurple = (newIsPurple: boolean) => { isPurple = newIsPurple;};render(<TogglePurple isPurple={isPurple} setIsPurple={setIsPurple} />);
test("TogglePurple checkbox", () => { const checkboxElement = screen.getByRole("checkbox"); expect(checkboxElement).toBeInTheDocument(); // not 否定断言 expect(checkboxElement).not.toBeChecked();});
test("TogglePurple label", () => { const labelElement = screen.getByText(/purple/i); expect(labelElement).toBeInTheDocument();});import { describe, test, it } from "vitest";- describe 测试分组
- it 等价于 test
globals 全局 API
Section titled “globals 全局 API”如果 vitest 配置了 globals 全局 API, 则 @testing-library/react 会使用全局的 afterEach() 自动调用 cleanup(), 卸载通过 render() 挂载的 react 组件树
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"], },});// 保留样式import "@/index.css";
// 副作用导入 @testing-library/jest-dom/vitestimport "@testing-library/jest-dom/vitest";{ "compilerOptions": { "types": ["vite/client", "vitest/globals"] }, "include": ["src", "tests"]}优化测试
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(); });});测试用户交互
Section titled “测试用户交互”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(); }); });});import { render, screen } from "@testing-library/react";import TogglePurple from "@/components/toggle-purple";import userEvent from "@testing-library/user-event";import { useState } from "react";
function TogglePurple4mock() { const [isPurple, setIsPurple] = useState(false); return <TogglePurple isPurple={isPurple} setIsPurple={setIsPurple} />;}
describe("TogglePurple", () => { beforeEach(() => { render(<TogglePurple4mock />); });
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
Section titled “Vitest UI”pnpm add @vitest/ui -Dpnpm exec vitest --uiimport { 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}"], }, },});测试键盘输入
Section titled “测试键盘输入”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 浏览器模式
Section titled “Vitest 浏览器模式”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;.active { color: #fff; background: #fb2c36;}import { render, screen } from "@testing-library/react";import userEvent from "@testing-library/user-event";import Button from "@/components/button";
it("should display red color after user click", async () => { // Arrange render(<Button />); const buttonElement = screen.getByRole("button"); const user = userEvent.setup();
// Act await user.click(buttonElement);
// Assert expect(buttonElement).toHaveClass("active"); expect(buttonElement).toHaveStyle({ backgroundColor: "hex(#fb2c36)", });
const actualBackgroundColor = window.getComputedStyle(buttonElement).backgroundColor; // Expected: "rgb(251, 44, 54)" // Received: "rgba(0, 0, 0, 0)" // 解决方法: 使用 vitest 浏览器模式 expect(actualBackgroundColor).toBe("rgb(251, 44, 54)");});pnpm add -D \ jsdom \ playwright \ vitest \ vitest-browser-react \ @vitest/browser-playwright \ @vitest/coverage-v8 \ @vitest/ui/// <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" }], }, },});import Button from "@/components/button";// import { render, screen } from "@testing-library/react";import { render } from "vitest-browser-react";// import userEvent from "@testing-library/user-event";import { userEvent } from "vitest/browser";
it("should display red color after user click", async () => { // Arrange const renderResult = await render(<Button />); const buttonLocator = renderResult.getByRole("button");
// Act await userEvent.click(buttonLocator); renderResult.debug();
// Assert const backgroundColor = window.getComputedStyle( buttonLocator.element(), ).backgroundColor; expect(backgroundColor).toBe("rgb(251, 44, 54)");});<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>import NoteItem from "@/components/note-item";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";
describe("NoteItem", () => { const mockNote: Note = { id: 1, title: "Test Note", content: "This is a test note.", };
const queryClient = new QueryClient();
async function renderComponent() { const renderResult = await render( <QueryClientProvider client={queryClient}> <ModalContextProvider> <NoteItem note={mockNote} /> </ModalContextProvider> </QueryClientProvider>, ); return renderResult; }
it("should render note item", async () => { const renderResult = await renderComponent(); const { getByRole } = renderResult; const deleteButtonLocator = getByRole("button", { // 匹配 aria-label="delete-button" name: "delete-button", }); await expect.element(deleteButtonLocator).toBeInTheDocument(); });});vi utility
Section titled “vi utility”vi.mock()vi.mocked()vi.waitFor()vi.importActual()- …
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); }); });});import EditModal from "@/components/edit-modal";import ModalContext from "@/context/modal-context";import type { Note } from "@/schema";import { QueryClient, QueryClientProvider } from "@tanstack/react-query";import { useAtomValue } from "jotai";import { type PropsWithChildren, useState } from "react";import { Toaster } from "sonner";import { render } from "vitest-browser-react";import { userEvent } from "vitest/browser";import worker from "../mocks/worker";
const ModalContextProvider = ({ children }: NonNullable<PropsWithChildren>) => { const [isOpen, setIsOpen] = useState(true); return ( <ModalContext.Provider value={{ isOpen, openModal: () => setIsOpen(true), closeModal: () => setIsOpen(false), }} > {children} </ModalContext.Provider> );};
const WrappedProvider = ({ children }: NonNullable<PropsWithChildren>) => { const queryClient = new QueryClient({ defaultOptions: { queries: { // 请求失败时, 不重试 retry: false, }, }, }); return ( <QueryClientProvider client={queryClient}> <ModalContextProvider>{children}</ModalContextProvider> </QueryClientProvider> );};
describe("EditModal", () => { beforeAll(() => worker.start()); afterEach(() => worker.resetHandlers()); afterAll(() => worker.stop());
const renderComponent = async () => { return render( <> <EditModal /> <Toaster /> </>, { wrapper: WrappedProvider }, ); };
vi.mock("jotai", async () => { const originalModule = await vi.importActual("jotai"); return { ...originalModule, useAtomValue: vi.fn() }; });
const mockedNote4add: Note = { id: NaN, title: "Mocked Title", content: "Mocked Content", };
describe("render test", () => { it("should render the add note modal with default values", async () => { vi.mocked(useAtomValue).mockReturnValue(mockedNote4add); const { getByLabelText } = await renderComponent(); const titleLocator = getByLabelText(/title/i); const contentLocator = getByLabelText(/content/i); await expect.element(titleLocator).toHaveValue(mockedNote4add.title); await expect.element(contentLocator).toHaveValue(mockedNote4add.content); });
it("should render the toast after user click the add button", async () => { vi.mocked(useAtomValue).mockReturnValue(mockedNote4add); const { getByRole, getByText } = await renderComponent(); const addButtonLocator = getByRole("button", { name: /add/i }); await expect.element(addButtonLocator).toBeInTheDocument(); await userEvent.click(addButtonLocator); const toastLocator = getByText(/created/i); await expect.element(toastLocator).toBeInTheDocument(); }); });});// 保留样式import "@/index.css";
import { http, HttpResponse } from "msw";// For nodeimport { setupServer } from "msw/node";// For browserimport { 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 nodeconst server = setupServer(...handlers);beforeAll(() => server.listen());afterEach(() => server.resetHandlers());afterAll(() => server.close());
// For browserconst worker = setupWorker(...handlers);beforeAll(() => worker.start());afterEach(() => worker.resetHandlers());afterAll(() => worker.stop());export default defineConfig({ globals: true, // 浏览器模式 browser: { enabled: true, provider: playwright(), // https://vitest.dev/config/browser/playwright instances: [{ browser: "chromium" }], }, setupFiles: ["./tests/vitest.setup.ts"],});使用 MSW mock 接口
Section titled “使用 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
Section titled “使用 @mswjs/data mock CRUD”# 使用 @mswjs/data mock CRUD# 使用 faker-js mock 数据pnpm add @mswjs/data @faker-js/faker -Dimport { 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 -Dconst 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); }),];