React组件测试:Vitest浏览器模式与周边库实战解析

入门单元测试当然要使用最流行的 vitest,在这里仅使用vitest提供的周边库来实现单元测试。所以使用 vitest-browser-react 替代@testing-library/react。

目前vitest已经支持browser mode,即允许在浏览器中原生运行测试,并提供对浏览器全局变量,如 window 和 document 的访问。

在本地运行测试时,vitest支持复用已经安装好的浏览器。但是在跑流水线的时候,进行测试只能使用 playwright or webdriverio。Vitest建议在本地测试时就应该使用它们俩个包之一,而不是使用模拟事件的默认模式preview(用本地的浏览器)。

vitest建议使用 playwright,因为它支持并行执行而且使用 Chrome DevTools 协议,所以比 WebDriver 更快。

另外还需要vitest-browser-react渲染React组件

得出结论需要下载这4个包:

bash 复制代码
npm install -D vitest @vitest/browser playwright vitest-browser-react

接下来配置 vitest.config.ts

ts 复制代码
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    browser: {
      enabled: true,
      provider: "playwright",
      instances: [{ browser: "chromium" }],
    },
  },
});

配合typescript使用的话,需要确保在 tsconfig.json中include vitest的配置文件(vitest.config 或 vitest.setup 或 vitest.workplace),并且在types数组中加上类型。可参考 vitest react的例子。

json 复制代码
{
  "compilerOptions": {
    "types": [
      "@vitest/browser/providers/playwright" /* 加上使用的浏览器的类型 */
    ],
  },
  "include": [
    "src",
    "vitest.config.ts" /* 确保加上 */
  ]
  /* 省略其它配置 */
}

接下来,通过 4 个小例子来入门单元测试。

组件渲染测试

编写单元测试通常遵循'描述-示例-断言'模式:

  1. describe()方法定义被测试模块或功能
  2. 通过it()方法列举具体测试场景并明确预期结果
  3. 最后在测试用例中使用expect()断言来验证实际输出是否符合预期。
tsx 复制代码
describe('subtract function', () => {
    it('should return the difference between two numbers', () => {
      const result = subtract(5, 3);
      expect(result).toBe(2);
    });
});

以下是待测试的react组件:

tsx 复制代码
// Greeting.tsx
function Greeting({ name }: {name?:string}) {
  return <h1>Hello, {name || "World"}!</h1>;
}

export default Greeting;

编写测试用例,可以考虑这两个方面:

  • 如果没有提供 name ,显示 "Hello, World!"
  • 如果提供了 name ,显示 "Hello, {name}!"
tsx 复制代码
// Greeting.test.tsx
import { render } from "vitest-browser-react";
import { describe, it, expect } from "vitest";
import Greeting from "./Greeting";

describe("Greeting", () => {
  it("renders a default greeting", () => {
    const { getByText } = render(<Greeting />);
    expect(getByText("Hello, World!").element()).toBeInTheDocument();
  });

  it("renders a greeting with a name", () => {
    const { getByText } = render(<Greeting name="Jiaqi" />);
    expect(getByText("Hello, Jiaqi!").element()).toBeInTheDocument();
  });
});

使用命令 npx vitest 执行测试:

类似于 toBeInTheDocument 可在 jest-dom 或者vitest browser mode中查看。

这种以 get 开头的定位器可以在Locators这节查看更多API。

用户交互测试

上面的组件是纯渲染的,如果给组件增加了用户交互,应该如何测试:

tsx 复制代码
// Counter.tsx
import { useState } from "react"

const Counter = () => {
  const [count,setCount] = useState(0)
  return (
    <div>
      <p>{count}</p>
      <button onClick={()=>setCount(pre=>pre+1)}>increment</button>
    </div>
  )
}

export default Counter

测试逻辑:

  1. 断言p标签初始时的textContent是否为0
  2. 使用useEvent触发按钮点击
  3. 断言p标签此时的textContent为1
tsx 复制代码
import { render } from "vitest-browser-react";
import { describe, it, expect } from "vitest";
import Counter from "./Counter";
import { userEvent } from "@vitest/browser/context";

describe("Counter", async () => {
  it("increments counter on button click", async () => {
    const screen = render(<Counter />);
    // 获取按钮 在Counter组件中按钮的名字是increment
    const btn = screen.getByRole("button", { name: /increment/ });
    // 获取p标签
    const counterValue = screen.getByRole("paragraph");
    expect(counterValue.element()).toHaveTextContent("0"); // 默认应该为0
    // 用户触发按钮点击,userEvent来自@vitest/browser/context
    await userEvent.click(btn);
    expect(counterValue.element()).toHaveTextContent("1");
  });
});

在这里恰巧只有一个p标签,若考虑到复杂情况,可以给p加上一个 data-testid。不过vitest建议,只有当其它的选择器无法使用的时候才考虑使用 data-testid

测试有请求的组件

以下是一个使用 fetch 发送请求的组件:

tsx 复制代码
// UserProfile.tsx
import { useEffect, useState } from "react";

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<{ name: string; email: string } | null>(null);

  useEffect(() => {
    fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
      .then((res) => res.json())
      .then((data) => setUser(data));
  }, [userId]);

  if (!user) return <p>Loading...</p>;
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

export default UserProfile;

但是在单元测试中会直接mock数据,避免实际发送请求。使用真实 API 可能会因为数据变化导致测试失败。此外Mock 数据保持一致,确保测试结果可重复。

因为单元测试的核心理念是隔离被测试的代码单元,确保测试只关注组件本身的行为,而不依赖于外部系统。

因此在测试用例中加上这两个函数:

  • beforeEach : 在每个测试用例执行前运行,这里使用 vi.stubGlobal 模拟全局的 fetch 函数
  • afterEach : 在每个测试用例执行后运行,清理所有模拟并恢复全局函数
tsx 复制代码
beforeEach(() => {
  vi.stubGlobal("fetch", vi.fn());
});

afterEach(() => {
  vi.clearAllMocks();
  vi.unstubAllGlobals();
});

vi.stubGlobal("fetch", vi.fn()) 这行代码做了以下事情:

  1. 将全局的 fetch 函数替换为一个由 Vitest 创建的模拟函数(mock function)
  2. 这个模拟函数默认不做任何事情,只是一个可以被跟踪和控制的空函数
tsx 复制代码
import { render } from "vitest-browser-react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import UserProfile from "./UserProfile";

describe("UserProfile", () => {
  beforeEach(() => {
    // Mock the global fetch function
    vi.stubGlobal("fetch", vi.fn());
  });

  afterEach(() => {
    // Clear all mocks after each test
    vi.clearAllMocks();
    vi.unstubAllGlobals();
  });

  it("fetches and displays the user data", async () => {
    const fetchMock = vi.mocked(fetch);
    const mockUser = { name: "John Doe", email: "john@example.com" };
    // 提前设置fetch的返回值
    fetchMock.mockResolvedValue({
      json: () => Promise.resolve(mockUser),
    } as unknown as Response);
    
    // 渲染组件,UserProfile在useEffect调用的fetch被替换成设置的fetchMock
    const screen = render(<UserProfile userId="1" />);
    // 渲染后页面上有loading文字的加载
    expect(screen.getByText(/loading/i).element()).toBeInTheDocument();
    expect(fetchMock).toHaveBeenCalledWith("https://jsonplaceholder.typicode.com/users/1");
    // 这里使用了retry的语法
    await expect.element(screen.getByText("John Doe")).toBeInTheDocument();
    await expect.element(screen.getByText("john@example.com")).toBeInTheDocument();
  });
});

这里除了直接给模拟的fetch设置好返回值,还可以编写fetch的模拟实现:

tsx 复制代码
// 设置返回的promise
fetchMock.mockResolvedValue({
      json: () => Promise.resolve(mockUser),
    } as unknown as Response);
    
// 直接设置fetchMock的模拟实现
fetchMock.mockImplementation(() =>
      Promise.resolve({
        json: () => Promise.resolve(mockUser),
      } as unknown as Response)
);

由于浏览器的异步特性,vitest提供了可重试的断言语法,可以参考这里

非常适合这里展示用户信息时的断言:

tsx 复制代码
// 这里使用了retry的语法
await expect.element(screen.getByText("John Doe")).toBeInTheDocument();
await expect.element(screen.getByText("john@example.com")).toBeInTheDocument();

hook测试

写一个简单的 hook useCounter.ts

tsx 复制代码
import { useState } from "react";

export const useCounter = (initialValue: number = 0) => {
  const [count, setCount] = useState(initialValue);
  const increment = () => {
    setCount((c) => c + 1);
  };
  const decrement = () => {
    setCount((c) => c - 1);
  };

  return {
    count,
    increment,
    decrement,
  };
};

Counter.tsx 中使用它:

tsx 复制代码
import { useCounter } from "./hooks/useCounter"

const Counter = () => {
  const {count,increment }  = useCounter()
  return (
    <div>
      <p data-testid='counter-value'>{count}</p>
      <button onClick={increment}>increment</button>
    </div>
  )
}

export default Counter

之前在 Counter.test.tsx 写的测试用例依然可以用于 Counter 组件,但是应该如何测试 useCounter hook?

参考vitest-browser-react文档可知,使用 renderHook 来测试,同时使用 react 的 act api更新状态。act 是个测试辅助工具,用于在断言前应用待处理的 React 更新。

所以最终的测试文件可以这样写:

tsx 复制代码
import { renderHook } from "vitest-browser-react";
import { useCounter } from "./useCounter";
import { describe, it, expect } from "vitest";
import { act } from "react";

describe("useCounter", () => {
  it("initial value is 2", () => {
    // 使用renderHook渲染hook
    const { result } = renderHook(() => useCounter(2));
    expect(result.current.count).toBe(2);
  });
  it("increment", () => {
    const { result } = renderHook(() => useCounter());
    // 调用increment
    expect(result.current.count).toBe(0);
    act(() => {
      result.current.increment();
    });
    expect(result.current.count).toBe(1);
  });

  it("decrement", () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
    act(() => {
      result.current.decrement();
    });
    expect(result.current.count).toBe(-1);
  });
});

总结

核心工具与环境配置

  1. 工具选择

    • 使用 vitest + vitest-browser-react 替代传统 @testing-library/react
    • 浏览器模式支持:Vitest 原生支持浏览器环境,通过 @vitest/browser 集成 Playwright 或 Webdriver
    • Playwright 优势:并行执行、基于 Chrome DevTools 协议,速度更快
  2. 依赖安装

    bash 复制代码
    npm install -D vitest @vitest/browser playwright vitest-browser-react
  3. 配置要点

    • Vitest 配置 :启用浏览器模式,指定 Playwright 和 Chromium

      ts 复制代码
      test: {
        browser: {
          enabled: true,
          provider: "playwright",
          instances: [{ browser: "chromium" }],
        },
      }
    • TypeScript 配置 :包含 Vitest 类型声明和配置文件路径

      json 复制代码
      "types": ["@vitest/browser/providers/playwright"],
      "include": ["src", "vitest.config.ts"]

测试模式与实例

  1. 基础测试结构

    描述-示例-断言模式

    ts 复制代码
     describe("模块", () => {
       it("场景", () => {
         expect(实际值).匹配器(预期值);
       });
     });
  2. 组件测试场景

    • 渲染测试 :验证默认值与 Props 传递

      tsx 复制代码
      expect(getByText("Hello, World!")).toBeInTheDocument();
    • 交互测试 :使用 userEvent 模拟点击,验证状态更新

      tsx 复制代码
      await userEvent.click(按钮);
      expect(元素).toHaveTextContent("1");
  3. API 请求测试

    • Mock Fetch :隔离外部依赖,确保测试稳定性

      tsx 复制代码
      vi.stubGlobal("fetch", vi.fn());
      fetchMock.mockResolvedValue({ json: () => mockData });
    • 加载态与数据渲染断言

      tsx 复制代码
      await expect.element(数据元素).toBeInTheDocument();
  4. Hook 测试

    • renderHook + act :测试自定义 Hook 状态与逻辑

      tsx 复制代码
      const { result } = renderHook(() => useCounter());
      act(() => result.current.increment());
      expect(result.current.count).toBe(1);

最佳实践与注意事项

  1. 选择器优先级 :优先使用语义化角色(如 getByRole),避免过度依赖 data-testid
  2. 测试隔离 :通过 beforeEach/afterEach 清理 Mock 和状态
  3. 浏览器环境适配:本地使用真实浏览器,流水线集成 Playwright
  4. 核心原则
    • 单元测试需独立于外部服务(如 API)
    • 聚焦组件自身逻辑,Mock 外部依赖

通过以上步骤,可快速搭建基于 Vitest 的 React 单元测试环境,覆盖组件渲染、交互、异步请求及 Hook 等场景,确保代码质量与可维护性。

参考信息:

相关推荐
程序员爱钓鱼1 分钟前
使用 Node.js 批量导入多语言标签到 Strapi
前端·node.js·trae
鱼樱前端2 分钟前
uni-app开发app之前提须知(IOS/安卓)
前端·uni-app
V***u4533 分钟前
【学术会议论文投稿】Spring Boot实战:零基础打造你的Web应用新纪元
前端·spring boot·后端
i听风逝夜41 分钟前
Web 3D地球实时统计访问来源
前端·后端
iMonster1 小时前
React 组件的组合模式之道 (Composition Pattern)
前端
呐呐呐呐呢1 小时前
antd渐变色边框按钮
前端
元直数字电路验证1 小时前
Jakarta EE Web 聊天室技术梳理
前端
wadesir1 小时前
Nginx配置文件CPU优化(从零开始提升Web服务器性能)
服务器·前端·nginx
牧码岛1 小时前
Web前端之canvas实现图片融合与清晰度介绍、合并
前端·javascript·css·html·web·canvas·web前端
灵犀坠1 小时前
前端面试八股复习心得
开发语言·前端·javascript