如何给 react 组件写单测

目前前端开发中,基本都是组件化开发。好的组件可以极大地提高代码的复用性。一些功能相对稳定的组件或者hooks,为了保证组件修改维护后的功能正常,这时候就需要单元测试。有了单元测试之后,组件代码修改之后只需要跑一遍单元测试就知道功能是否正常,可以提高效率,减少出错概率。

那么,如何写React的组件或hooks的单测呢?

目前常用的是用jest测试框架和React Testing Library (RTL) 结合来写React的组件、hooks的单测。

jest测试框架的基本使用可以参照笔者的另一篇文章:juejin.cn/post/745746...

下面一起来实践一下

搭建项目

从零开始,新建一个文件夹并执行npm init初始化项目

安装相关依赖

主要安装以下依赖

React 相关依赖

  • react

  • react-dom

javascript 复制代码
npm install react react-dom
  • @types/react
  • @types/react-dom
javascript 复制代码
npm install @types/react @types/react-dom -D

jest和babel相关依赖

  • jest
  • babel-jest
  • @types/jest
  • identity-obj-proxy (方便地模拟 CSS 模块导入)
  • jest-environment-jsdom (jsdom 模拟浏览器环境)
  • @babel/core
  • @babel/preset-env
  • @babel/preset-react
  • @babel/preset-typescript
javascript 复制代码
npm install jest babel-jest @types/jest identity-obj-proxy jest-environment-jsdom @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript -D

React Testing Library 相关依赖

  • @testing-library/jest-dom
  • @testing-library/react
  • @testing-library/user-event
javascript 复制代码
npm install @testing-library/jest-dom @testing-library/react @testing-library/user-event -D

typescript 依赖

  • typescript
javascript 复制代码
npm install typescript -D

添加相关配置文件

添加babel配置文件

添加babel.config.json

json 复制代码
{
  "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"]
}

添加jest配置文件

添加jest.setup.js

javascript 复制代码
import "@testing-library/jest-dom";

添加jest.config.js

javascript 复制代码
module.exports = {
  testEnvironment: "jsdom", // 使用 jsdom 模拟浏览器环境
  setupFilesAfterEnv: ["<rootDir>/jest.setup.js"], // 配置 Jest 环境
  transform: {
    "^.+\\.[t|j]sx?$": "babel-jest", // 使用 babel-jest 来转换文件
  },
  moduleNameMapper: {
    "\\.(css|less|sass|scss)$": "identity-obj-proxy", // Mock 样式文件
  },
};

组件简单测试

先写一个简单的button组件

tsx 复制代码
import React from "react";

function Button() {
  return <div className="my-button">button</div>;
}

export default Button;

再写对应的单测文件

javascript 复制代码
import React from "react";
import { render, screen } from "@testing-library/react";
import Button from "./index.tsx";
import "@testing-library/jest-dom";

test("renders button", () => {
  render(<Button />);
  const button = screen.getByText(/button/i);
  expect(button).toBeInTheDocument();
});

单测文件的流程大概是

1、通过@testing-library/react库中的render方法渲染<Button />组件

2、通过@testing-library/react库中的screen来查询 dom,查找特定的文本节点

3、然后通过jest断言它在document中

然后在package.json文件中添加

json 复制代码
"scripts": {
    "test": "jest"
},

跑下npm run test即可看到对应的结果

可以看到,测试是通过的

上面的测试文件也可以写成这样

javascript 复制代码
import React from "react";
import { render } from "@testing-library/react";
import Button from "./index.tsx";
import "@testing-library/jest-dom";

test("renders button", () => {
  const { getByText } = render(<Button />);
  const button = getByText(/button/i);
  expect(button).toBeInTheDocument();
});

或者是这样

javascript 复制代码
import React from "react";
import { render } from "@testing-library/react";
import Button from "./index.tsx";
import "@testing-library/jest-dom";

test("renders button", () => {
  const { container } = render(<Button />);
  const button = container.querySelector(".my-button");
  expect(button).toBeInTheDocument();
  expect(button.textContent).toMatch(/button/i);
});

可见,render函数返回了一个对象,该对象包含一些属性和方法,其中还包含组件挂载的容器dom,它是一个HTMLElement对象,上面有各种我们熟悉的dom方法,例如上面例子的querySelector方法。

查询

上面例子中用了getByText的查询方式,它会搜索有文本节点并且textContent属性和查询参数匹配的元素。

除了这个查询方法外,还有很多其他的查询方法。

查询类型可以分成三种类型:"get"、"find"、"query",三种类型的查询方式不同

  • getBy...:返回查询的匹配节点,如果没有元素匹配或者 找到多个匹配项,则抛出描述性错误(如果需要查找多个元素,需要使用getAllBy...)

    javascript 复制代码
    import React from "react";
    import { render, screen } from "@testing-library/react";
    import Button from "./index.tsx";
    import "@testing-library/jest-dom";
    
    test("renders button", () => {
      render(<Button />);
      const table = screen.getByText(/table/i);
      expect(table).not.toBeInTheDocument();
    });
    
    // 会直接报错
    // TestingLibraryElementError: Unable to find an element with the text: /table/i. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
  • findBy...:返回一个 Promise,当找到与给定查询匹配的元素时,该 Promise 将被解析。如果未找到任何元素,或者在默认超时 1000 毫秒后找到多个元素(如果需要查找多个元素,需要使用findAllBy...),则该 Promise 将被拒绝。

    javascript 复制代码
    import React from "react";
    import { render, screen } from "@testing-library/react";
    import Button from "./index.tsx";
    import "@testing-library/jest-dom";
    
    test("renders button", () => {
      render(<Button />);
      const button = screen.findByText(/button/i);
      expect(button).resolves.toBeInTheDocument();
    });
  • queryBy...:返回查询的匹配节点,如果没有元素匹配则返回null。这对于断言不存在的元素很有用。如果找到多个匹配项,则抛出错误(如果需要查找多个元素,则使用queryAllBy...。)

    javascript 复制代码
    import React from "react";
    import { render, screen } from "@testing-library/react";
    import Button from "./index.tsx";
    import "@testing-library/jest-dom";
    
    test("renders button", () => {
      render(<Button />);
      const table = screen.queryByText(/table/i);
      expect(table).toBeNull();
    });

除了上面的这种查询包含特定文本内容外,还有很多其他的查询方式,包括查单个和多个

ByText

查询包含特定文本内容的任意元素。

  • getByText
  • findByText
  • queryByText
  • getAllByText
  • findAllByText
  • queryAllByText

ByRole

查询具有给定角色的元素(默认角色会被考虑在内)

  • getByRole
  • findByRole
  • queryByRole
  • getAllByRole
  • queryAllByRole
  • findAllByRole
tsx 复制代码
// 组件
import React from "react";
function Page() {
  return (
    <div>
      <nav role="navigation"></nav>
      <button></button> {/* button有默认button角色 */}
    </div>
  );
}
export default Page;
tsx 复制代码
// test文件
import React from "react";
import { render, screen } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";

test("byRole", () => {
  render(<Page />);
  const navigation = screen.getByRole("navigation");
  expect(navigation).toBeInTheDocument();
  const button = screen.getByRole("button");
  expect(button).toBeInTheDocument();
});

ByLabelText

查询与给定匹配的"label",主要用于查找与指定标签文本关联的表单控件,例如 <input>, <textarea>, <select> 等。通过 <label> 元素的 for 属性或嵌套关系来查找对应的表单控件。

  • getByLabelText
  • findByLabelText
  • queryByLabelText
  • getAllByLabelText
  • findAllByLabelText
  • queryAllByLabelText
tsx 复制代码
// 组件
import React from "react";

function Page() {
  return (
    <div>
      <label htmlFor="username-input">label-one</label>
      <input id="username-input" />

      <label id="username-label">label-two</label>
      <input aria-labelledby="username-label" />

      <label>
        label-three <input />
      </label>

      <label>
        <span>label-four</span>
        <input />
      </label>

      <input aria-label="label-five" />
    </div>
  );
}
export default Page;
tsx 复制代码
// test文件
import React from "react";
import { render, screen } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";

test("byLabelText", () => {
  render(<Page />);
  const label1 = screen.getByLabelText("label-one");
  expect(label1).toBeInTheDocument();
  const label2 = screen.getByLabelText("label-two");
  expect(label2).toBeInTheDocument();
  const label3 = screen.getByLabelText("label-three");
  expect(label3).toBeInTheDocument();
  const label4 = screen.getByLabelText("label-four");
  expect(label4).toBeInTheDocument();
  const label5 = screen.getByLabelText("label-five");
  expect(label5).toBeInTheDocument();
});

ByPlaceholderText

查询占位符属性匹配的元素

  • getByPlaceholderText
  • findByPlaceholderText
  • queryByPlaceholderText
  • getAllByPlaceholderText
  • findAllByPlaceholderText
  • queryAllByPlaceholderText
tsx 复制代码
// 组件
import React from "react";

function Page() {
  return (
    <div>
      <input placeholder="name" />
    </div>
  );
}
export default Page;
tsx 复制代码
// test文件
import React from "react";
import { render, screen } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";

test("ByPlaceholderText", () => {
  render(<Page />);
  const input = screen.getByPlaceholderText("name");
  expect(input).toBeInTheDocument();
});

ByDisplayValue

查询具有匹配显示值的inputtextareaselect或其他元素。

  • getByDisplayValue
  • findByDisplayValue
  • queryByDisplayValue
  • getAllByDisplayValue
  • findAllByDisplayValue
  • queryAllByDisplayValue
tsx 复制代码
import React, { useEffect } from "react";

function Page() {
  useEffect(() => {
    const myTextArea = document.getElementById(
      "myTextArea"
    ) as HTMLInputElement;
    myTextArea.value = "Hello World";
  }, []);

  return (
    <div>
      <input type="text" defaultValue="jack" />
      <textarea id="myTextArea" />
    </div>
  );
}

export default Page;
tsx 复制代码
import React from "react";
import { render, screen } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";

test("ByDisplayValue", () => {
  render(<Page />);
  const input = screen.getByDisplayValue("jack");
  const textArea = screen.queryByDisplayValue("Hello World");
  expect(input).toBeInTheDocument();
  expect(textArea).toBeInTheDocument();
});

如果是select元素的话,会查询和<select>选定的<option>相匹配的

tsx 复制代码
import React from "react";

function Page() {
  return (
    <div>
      <select defaultValue="three">
        <option value="one">One</option>
        <option value="two">Two</option>
        <option value="three">Three</option>
      </select>
    </div>
  );
}

export default Page;
tsx 复制代码
import React from "react";
import { render, screen } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";

test("ByDisplayValue", () => {
  render(<Page />);
  const select1 = screen.getByDisplayValue("Three");
  const select2 = screen.queryByDisplayValue("One");
  expect(select1).toBeInTheDocument();
  expect(select2).toBeNull();
});

ByAltText

查询和alt属性相匹配的元素(一般是<img>

  • getByAltText
  • findByAltText
  • queryByAltText
  • getAllByAltText
  • findAllByAltText
  • queryAllByAltText
tsx 复制代码
import React from "react";

function Page() {
  return <img src="../default.png" alt="my-img" />;
}

export default Page;
tsx 复制代码
import React from "react";
import { render, screen } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";

test("ByAltText", () => {
  render(<Page />);
  const img = screen.getByAltText("my-img");
  expect(img).toBeInTheDocument();
});

ByTitle

查询和title属性相匹配的元素

  • getByTitle
  • findByTitle
  • queryByTitle
  • getAllByTitle
  • findAllByTitle
  • queryAllByTitle
tsx 复制代码
import React from "react";

function Page() {
  return (
    <div>
      <span title="Open"></span>
      <svg>
        <title>Close</title>
        <g>
          <path />
        </g>
      </svg>
    </div>
  );
}
export default Page;
tsx 复制代码
import React from "react";
import { render, screen } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";

test("ByTitle", () => {
  render(<Page />);
  const openElement = screen.getByTitle("Open");
  const closeElement = screen.getByTitle("Close");
  expect(openElement).toBeInTheDocument();
  expect(closeElement).toBeInTheDocument();
});

ByTestId

查询和data-testid匹配的元素,可以当成是"container.querySelector([data-testid="${yourId}"])"的快捷查询方式

  • getByTestId
  • findByTestId
  • queryByTestId
  • getAllByTestId
  • findAllByTestId
  • queryAllByTestId
tsx 复制代码
import React from "react";

function Page() {
  return <div data-testid="my-data-testid" />;
}

export default Page;
tsx 复制代码
import React from "react";
import { render, screen } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";

test("ByTestId", () => {
  render(<Page />);
  const element = screen.getByTestId("my-data-testid");
  expect(element).toBeInTheDocument();
});

用户操作

事件

clickchange这些事件要怎么测试呢,testing-library提供了一个fireEvent方法。

看下下面的例子,一个简单的点击数字递增的组件

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

function Page() {
  const [num, setNum] = useState(0);
  const add = () => {
    setNum((val) => ++val);
  };
  return (
    <div>
      <p className="num-val">{num}</p>
      <button onClick={add}>add</button>
    </div>
  );
}

export default Page;
tsx 复制代码
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";

test("click", () => {
  const { container } = render(<Page />);
  const numVal = container.querySelector(".num-val");
  expect(numVal.textContent).toBe("0");
  const button = screen.getByText("add");
  fireEvent.click(button);
  expect(numVal.textContent).toBe("1");
});

上面的test文件的流程大概如下

1、渲染组件

2、用 container 节点的 dom api 根据classname查询到 显示数字内容的 P 标签

3、断言该标签的文本节点是 0

4、查询拿到 button 标签

5、用fireEvent.click模拟触发 button 的点击事件

6、断言 P 标签的文本节点是 1

运行npm run test后可以发现测试通过了。

fireEvent 可以触发任何元素的任何事件。其用法为fireEvent[eventName] (node: HTMLElement, eventProperties: Object)

eventProperties上有许多属性,比较常用的有target属性,当提供target属性时,这些属性将分配给接收事件的节点。这对于change事件特别有用。

tsx 复制代码
import React from "react";
function Page() {
  return (
    <label>
      date-input
      <input type="date" />
    </label>
  );
}
export default Page;
tsx 复制代码
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";

test("change", () => {
  render(<Page />);
  const dateInput = screen.getByLabelText("date-input");
  expect(dateInput.value).toBe("");
  fireEvent.change(dateInput, { target: { value: "2020-05-20" }});
  expect(dateInput.value).toBe("2020-05-20"); 
});

还有dataTransfer属性,用于拖动事件

tsx 复制代码
fireEvent.drop(getByLabelText(/drop files/i), {
  dataTransfer: {
    files: [new File(['xxx'], 'default.png', {type: 'image/png'})],
  },
})

键盘事件相关属性,keyPresskeyDownkeyUp等键盘事件在触发时,需要引用 DOM 中的元素和要触发的键。

javascript 复制代码
fireEvent.keyDown(domNode, {key: 'Enter', code: 'Enter', charCode: 13})
fireEvent.keyDown(domNode, {key: 'A', code: 'KeyA'})

user-event

fireEvent允许开发人员触发任何元素上的任何事件,但是并不是事件的整个完整模拟。例如点击事件,fireEvent.click会创建一个点击事件并在给定的 DOM 节点上调度该事件。但是实际用户点击某个元素时,会触发鼠标移动、聚焦、点击等一系列事件。所以当我们需要在真实环境中测试组件时,我们就需要用到@testing-library/user-event

例如下面的例子,按钮在聚焦时会触发事件从而改变文本内容。

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

function Page() {
  const [isFocus, setIsFocus] = useState(false);
  function buttonFocus() {
    setIsFocus(true);
  }
  return (
    <div>
      <p className="my-text">{isFocus ? "focus" : "out of focus"}</p>
      <button onFocus={buttonFocus} className="my-button"></button>
    </div>
  );
}

export default Page;

如果我们想要在测试代码中测试点击按钮来触发按钮的聚焦事件,按照下面的测试文件,测试结果会报错。

tsx 复制代码
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";

test("event", async () => {
  const { container } = render(<Page />);
  const button = container.querySelector(".my-button");
  fireEvent.click(button);
  const text = container.querySelector(".my-text");
  expect(text.textContent).toBe("focus");
});

我们需要借助@testing-library/user-event来模拟真实点击。

tsx 复制代码
import React from "react";
import { render } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";
import userEvent from "@testing-library/user-event";

test("event", async () => {
  const user = userEvent.setup();
  const { container } = render(<Page />);
  const button = container.querySelector(".my-button");
  await user.click(button);
  const text = container.querySelector(".my-text");
  expect(text.textContent).toBe("focus");
});

运行代码,可以发现,测试通过了。

异步

waitFor

当需要等待任意一段时间时,可以使用waitFor

例如改动一下上面点击数字递增的例子。

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

function Page() {
  const [num, setNum] = useState(0);
  const add = () => {
    setTimeout(() => {
      setNum((val) => ++val);
    }, 2000); // 增加定时器
  };
  return (
    <div>
      <p className="num-val">{num}</p>
      <button onClick={add}>add</button>
    </div>
  );
}

export default Page;

修改完成后,如果还是按照之前的测试代码运行则会报错。我们需要在测试代码中增加等待,这时就需要用到waitFor

修改测试代码如下

tsx 复制代码
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";

test("click", async () => {
  const { container } = render(<Page />);
  const numVal = container.querySelector(".num-val");
  expect(numVal.textContent).toBe("0");
  const button = screen.getByText("add");
  fireEvent.click(button);
  await waitFor(
    () => {
      expect(numVal.textContent).toBe("1");
    },
    { timeout: 3000 }
  );
});

运行后可以发现测试通过了

其实前面查询里的findBy...查询是getBy...waitFor的结合,它接收waitFor选项作为最后一个参数,即 await screen.findByText('text', queryOptions, waitForOptions)

hooks测试

hooks测试需要借助renderHookApi

假如有下面一个简单的hooks。

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

export default function useAdd(initNum = 0) {
  const [num, setNum] = useState(0);

  const add = () => {
    setNum((num) => ++num);
  };

  return [num, add];
}

这个hooks的单测可以是:

typescript 复制代码
import { renderHook, act } from "@testing-library/react";
import useAdd from "./index.ts";

test("hook test", async () => {
  const hook = renderHook(() => useAdd(11));
  const [num, add] = hook.result.current;
  act(() => {
    add();
  });
  expect(hook.result.current[0]).toBe(12);
  hook.unmount();
});
相关推荐
古蓬莱掌管玉米的神6 小时前
vue3语法watch与watchEffect
前端·javascript
林涧泣6 小时前
【Uniapp-Vue3】uni-icons的安装和使用
前端·vue.js·uni-app
雾恋6 小时前
AI导航工具我开源了利用node爬取了几百条数据
前端·开源·github
拉一次撑死狗6 小时前
Vue基础(2)
前端·javascript·vue.js
祯民7 小时前
两年工作之余,我在清华大学出版社出版了一本 AI 应用书籍
前端·aigc
热情仔7 小时前
mock可视化&生成前端代码
前端
m0_748246357 小时前
SpringBoot返回文件让前端下载的几种方式
前端·spring boot·后端
wjs04067 小时前
用css实现一个类似于elementUI中Loading组件有缺口的加载圆环
前端·css·elementui·css实现loading圆环
爱趣五科技7 小时前
无界云剪音频教程:提升视频质感
前端·音视频
计算机-秋大田8 小时前
基于微信小程序的校园失物招领系统设计与实现(LW+源码+讲解)
java·前端·后端·微信小程序·小程序·课程设计