Skip to content

Vitest

第一个测试

配置

bash
pnpm add \
@testing-library/dom \
@testing-library/jest-dom \
@testing-library/react \
@testing-library/user-event \
@vitest/coverage-v8 \
@vitest/ui \
jsdom \
vitest -D
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",
    },
  }),
);
tsx
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;
tsx
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()

使用 act() 测试组件渲染

tsx
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");
});

Pitfall 陷阱

事件派发只有在 container 被添加到文档时才有效, 即 document.body.appendChild(container);

测试渲染

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;
tsx
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();
});

配置

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

globals 全局 API

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

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"],
  },
});
ts
// 保留样式
import "@/index.css";

// 副作用导入 @testing-library/jest-dom/vitest
import "@testing-library/jest-dom/vitest";
json
{
  "compilerOptions": {
    "types": ["vite/client", "vitest/globals"]
  },
  "include": ["src", "tests"]
}

优化测试

tsx
// 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();
  });
});

测试用户交互

tsx
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();
    });
  });
});
tsx
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

bash
pnpm add @vitest/ui -D
pnpm exec vitest --ui
tsx
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);
      },
    );
  });
});

测试覆盖率

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"],
    // 测试覆盖率
    coverage: {
      enabled: true,
      provider: "v8",
      include: ["src/**/*.{ts,tsx}"],
    },
  },
});

测试键盘输入

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

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;
css
.active {
  color: #fff;
  background: #fb2c36;
}
tsx
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)");
});

配置

bash
pnpm add \
@vitest/coverage-v8 \
@vitest/ui \
jsdom \
@vitest/browser-playwright \
vitest-browser-react \
vitest -D
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" }],
    },
  },
});
tsx
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)");
});

无障碍

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>
tsx
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

  • vi.mock()
  • vi.mocked()
  • vi.waitFor()
  • vi.importActual()
  • ...
tsx
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);
    });
  });
});
tsx
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();
    });
  });
});

MSW

配置

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());
ts
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 接口

tsx
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

bash
# 使用 @mswjs/data mock CRUD
# 使用 faker-js mock 数据
pnpm add @mswjs/data @faker-js/faker -D
ts
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);
  }),
];