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: "[email protected]" };
    // 提前设置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("[email protected]")).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("[email protected]")).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 等场景,确保代码质量与可维护性。

参考信息:

相关推荐
大土豆的bug记录3 小时前
鸿蒙进行视频上传,使用 request.uploadFile方法
开发语言·前端·华为·arkts·鸿蒙·arkui
maybe02093 小时前
前端表格数据导出Excel文件方法,列自适应宽度、增加合计、自定义文件名称
前端·javascript·excel·js·大前端
HBR666_3 小时前
菜单(路由)权限&按钮权限&路由进度条
前端·vue
A-Kamen3 小时前
深入理解 HTML5 Web Workers:提升网页性能的关键技术解析
前端·html·html5
锋小张5 小时前
a-date-picker 格式化日期格式 YYYY-MM-DD HH:mm:ss
前端·javascript·vue.js
鱼樱前端5 小时前
前端模块化开发标准全面解析--ESM获得绝杀
前端·javascript
yanlele5 小时前
前端面试第 75 期 - 前端质量问题专题(11 道题)
前端·javascript·面试
前端小白۞6 小时前
el-date-picker时间范围 编辑回显后不能修改问题
前端·vue.js·elementui
拉不动的猪6 小时前
刷刷题44(uniapp-中级)
前端·javascript·面试
Spider Cat 蜘蛛猫6 小时前
chrome插件开发之API解析-chrome.scripting.executeScript()
前端·chrome