React ㄨ Jest 测试组件
一、React Test Renderer
react-test-renderer 负责将组件输出成 JSON 对象以方便我们遍历、断言或是进行快照测试。
1、安装及配置
使用 Create React App 时:
直接安装 react-test-renderer 即可
yarn add --dev react-test-renderer
不使用 Create React App 时:
- 安装:
yarn add --dev jest babel-jest @babel/core @babel/preset-env @babel/preset-react react-test-renderer
- 在 package.json 中添加:
{
  "scripts": {
    "test": "jest"
  }
}
- 在根目录的 babel.config.cjs配置以下内容:
module.exports = {
  presets: [
    '@babel/preset-env',
    ['@babel/preset-react', {runtime: 'automatic'}],
  ],
};
2、API
react-test-renderer 提供一个 React 渲染器 TestRenderer,用于将 React 组件渲染成纯 JavaScript 对象,调用 TestRenderer 的 create 方法并传入要 render 的组件就可以获得一个 TestRenderer 的实例。该实例上存在着以下几个方法和属性:
- act(): 为断言准备一个组件。可以使用 act() 来包装 TestRenderer.create 和 testRenderer.update;
- toJSON(): 生成一个表示 render 结果的 JSON 对象。该对象中只包含像 <div>这样的原生节点,不会包含用户开发的组件信息,适用于快照测试;
- toTree(): 与 toJSON() 类似,但信息更详细,包含用户开发的组件信息;
- update(element): 通过传入一个新元素来更新上次 render 得到的组件树;
- umount(): 卸载内存中的树,同时触发相应的生命周期函数;
- getInstance(): 返回根节点对应的 React 组件实例。如果顶级组件是一个函数式组件,则无法获取;
- root: 该属性保存了根节点对应的测试实例,该实例提供了一系列方法便于编写断言;
- find() 与 findAll(): 用于查找符合特定条件的测试实例。区别在于 find() 会严格要求节点树只有 1 个满足条件的测试实例,如果没有或多于 1 个就会抛出异常,此区别同样适用于下面两组方法;
- findByType() 与 findAllByType(): 用于查找特定类型的测试实例,这里的类型可以是 <div>这种原生类型,也可以是 Link 这种用户编写的 React 组件;
- findByProps() 与 findAllByProps(): 用于查找 props 符合特定结构的测试实例。
- instance: 该测试实例对应的 React 组件实例。
3、基本用法
- 组件测试用例
- 组件
import renderer from 'react-test-renderer'; // 即上述的 TestRenderer
import Link from '../index';
it('changes the class when hovered', () => {
  // 创建一个 TestRenderer 实例
  const component = renderer.create(
    <Link page="http://www.facebook.com">Facebook</Link>,
  );
  // 返回一个已渲染的的树对象
  let tree = component.toJSON();
  expect(tree).toMatchSnapshot();
  // manually trigger the callback
  renderer.act(() => {
    tree.props.onMouseEnter();
  });
  // re-rendering
  tree = component.toJSON();
  expect(tree).toMatchSnapshot();
  // manually trigger the callback
  renderer.act(() => {
    tree.props.onMouseLeave();
  });
  // re-rendering
  tree = component.toJSON();
  expect(tree).toMatchSnapshot();
});
import { useState } from 'react';
const STATUS = {
  HOVERED: 'hovered',
  NORMAL: 'normal',
};
export default function Link({ page, children }) {
  const [status, setStatus] = useState(STATUS.NORMAL);
  const onMouseEnter = () => {
    setStatus(STATUS.HOVERED);
  };
  const onMouseLeave = () => {
    setStatus(STATUS.NORMAL);
  };
  return (
    <a
      className={status}
      href={page || '#'}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
    >
      {children}
    </a>
  );
}
运行 yarn test,生成测试快照:
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`changes the class when hovered 1`] = `
<a
  className="normal"
  href="http://www.facebook.com"
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  Facebook
</a>
`;
exports[`changes the class when hovered 2`] = `
<a
  className="hovered"
  href="http://www.facebook.com"
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  Facebook
</a>
`;
exports[`changes the class when hovered 3`] = `
<a
  className="normal"
  href="http://www.facebook.com"
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  Facebook
</a>
`;
如果组件或测试用例修改,组件快照和之前形成的快照不一致,会导致测试失败,可以通过 --updateSnapshot 来重新生成快照文件:
yarn test --updateSnapshot
# 或简写↓ 
yarn test -u
二、React Testing Libraryㅤ
React 测试库 React Testing Library 不面向组件代码的实现细节,而是模拟用户的交互方式,测试最终 DOM,对 React 组件测试非常友好。
1、安装及配置ㅤ
以下步骤适用于非 Create React App 项目(Create React App 均已安装并配置好了)
- 安装 @testing-library/react:
yarn add --dev @testing-library/react
- Jest 28 或更高版本需要单独安装 jest-environment-jsdom 包:
yarn add --dev jest-environment-jsdom
如果已经有 jest.config.js,则在 jest.config.js 中设置:
testEnvironment: "jsdom",
否则直接运行创建 jest.config.js,在运行时配置 environment 即可:
yarn test --init
- jest-dom 是 React Testing Libraryㅤ生态中为 Jest 提供自定义 DOM 元素匹配器的配套库,安装如下:
yarn add --dev @testing-library/jest-dom
根目录新建 jest-setup.js:
import '@testing-library/jest-dom'
然后在 jest.config.js 中配置:
setupFilesAfterEnv: ['<rootDir>/jest-setup.js'],
- user-event 是 React Testing Libraryㅤ生态中扩展 fireEvent 模拟用户交互的配套库,安装如下:
yarn add --dev @testing-library/user-event @testing-library/dom
2、错误解决
- 如果测试的组件中引入了 .scss 等文件,可能会出现以下错误:

根目录新建 file.mock.js 文件:
module.exports = {};
然后在 jest.config.js 中配置:
moduleNameMapper: {
  "\\.(scss|css|jpg|png|gif)$": "<rootDir>/file.mock.js"
},
- 如果运行测试时出现 React 18 不再支持 ReactDOM.render 的错误:

解决方式:
yarn add @testing-library/react@latest -D
3、Testing Library API
3-1、Queries
Query 通用前缀:
| 前缀 | 匹配到 0 项 | 匹配到 1 项 | 匹配到多项 | Retry (Async/Await) | 
|---|---|---|---|---|
| get | Throw error | 返回单个节点 | Throw error | ❌ | 
| getAll | Throw error | 返回节点数组 | 返回节点数组 | ❌ | 
| query | 返回 null | 返回单个节点 | Throw error | ❌ | 
| queryAll | 返回 [] | 返回节点数组 | 返回节点数组 | ❌ | 
| find | Throw error | 返回单个节点 | Throw error | ✅ | 
| findAll | Throw error | 返回节点数组 | 返回节点数组 | ✅ | 
Query 使用后缀:
- ByLabelText:用于表单,匹配 label
- ByPlaceholderText:用于表单,匹配占位符
- ByText:匹配查询文本节点
- ByDisplayValue:匹配输入框等表单元素当前值
- ByAltText:匹配 img 的 alt 属性
- ByTitle:匹配 title 属性或元素
- ByTestId:匹配 data-testid 属性
举个例子:
// 匹配指定文本是否在节点中
it('info', () => {
  const { queryByText } = render(<Loading info="加载中" />);
  expect(queryByText('加载中')).toBeInTheDocument();
})
// 匹配指定类名的节点
it('maxLength', () => {
  const { container } = render(<Input maxLength={15} />);
  expect(container.querySelector('.i-input--limit')).toBeInTheDocument();
})
// 匹配指定 test-id 的节点
it('maxWidth', () => {
  const { getByTestId } = render(
    <Breadcrumb>
      <Breadcrumb.Item>item1</Breadcrumb.Item>
      <Breadcrumb.Item maxWidth={80} data-testid='test-item'>item2</Breadcrumb.Item>
      <Breadcrumb.Item>item3</Breadcrumb.Item>
    </Breadcrumb>
  );
  expect(getByTestId('test-item')).toHaveStyle('max-width: 80px;');
});
3-2、fireEvent
fireEvent[eventName](node: HTMLElement, eventProperties: Object)
举个例子:
it('onClick', () => {
  const clickFn = jest.fn();
  const { container } = render(<Button onClick={clickFn} />);
  fireEvent.click(container.firstChild);
  expect(clickFn).toHaveBeenCalled();
});
4、jest-dom API
元素通用验证:
- toBeVisible:可见性(display、visibility、opacity、hidden 等)
- toBeInTheDocument:文档中是否存在该元素;
- toHaveAttribute:元素是否存在该属性;
- toHaveClass:元素是否存在该类名;
- toHaveStyle:元素是否存在该样式;
元素内容验证:
- toContainElement:元素中是否存在指定元素;
- toContainHTML:元素中是否该 HTML 字符串;
- toHaveTextContent:元素中是否存在文本内容,可正则匹配;
表单属性验证:
- toBeDisabled:元素是否被禁用;
- toBeEnabled:元素是否没被禁用;
- toBeInvalid:元素是否无效;
- toBeValid:元素是否有效;
- toBeRequired:元素是否为必填项;
- toBeChecked:元素是否为选中项;
- toHaveFocus:元素是否聚焦;
- toHaveValue:元素是否具有指定值;
- toHaveFormValues:表单是否拥有指定控件;
举个例子:
it('children', () => {
  const { queryByText } = render(<Button>foo</Button>);
  expect(queryByText('foo')).toBeInTheDocument();
});
5、user-event API
- click(element):单击
- dblClick(element):双击
- tripleClick(element):三击
- hover(element):悬浮
- unhover(element):不悬浮
- clear(element):清除可编辑元素
- selectOptions(element, values):表单选择
- deselectOptions(element, values):表单取消选择
- Keyboard(input):按键
- type(element, text, [options]):输入文本
- tab(options):模拟 tab 键(切换 focus)
- copy():复制
- cut():剪切
- paste([clipboardData]):粘贴
- upload(element, fileOrFiles):上传
举个例子:
it('keyDown', async () => {
  const onKeydownFn = jest.fn();
  const onEnterFn = jest.fn();
  const { container } = render(
    <Input onKeyDown={onKeydownFn} onEnter={onEnterFn} />,
  );
  const InputDOM = container.firstChild.firstChild;
  const user = userEvent.setup()
  await user.click(InputDOM)
  await user.keyboard('abc{enter}')
  expect(onEnterFn).toBeCalled();
  expect(onKeydownFn).toBeCalled();
});
6、基本用法
- Button 组件测试用例
- Button 组件
- Button 组件类型
import { render, fireEvent } from '@testing-library/react';
import Button from '../index';
describe('Button 组件测试', () => {
  it('children', () => {
    const { queryByText } = render(<Button>foo</Button>);
    expect(queryByText('foo')).toBeInTheDocument();
  });
  it('type', () => {
    const { container } = render(<Button type="success" />);
    expect(container.firstChild.classList.contains('i-button--type-success')).toBeTruthy();
  });
  it('variant', () => {
    const { container } = render(<Button variant="outline" />);
    expect(container.firstChild.classList.contains('i-button--variant-outline')).toBeTruthy();
  });
  it('active', () => {
    const { container } = render(<Button active />);
    expect(container.firstChild.classList.contains('i-button-active')).toBeTruthy();
  });
  it('disabled', () => {
    const clickFn = jest.fn();
    const { container } = render(<Button disabled onClick={clickFn} />);
    expect(container.firstChild).toBeDisabled();
    fireEvent.click(container.firstChild);
    expect(clickFn).toBeCalledTimes(0);
  });
  it('size', () => {
    const { container } = render(<Button size="small" />);
    expect(container.firstChild.classList.contains('i-button--size-small')).toBeTruthy();
  });
  it('shape', () => {
    const { container } = render(<Button shape="circle" />);
    expect(container.firstChild.classList.contains('i-button--shape-circle')).toBeTruthy();
  });
  it('onClick', () => {
    const clickFn = jest.fn();
    const { container } = render(<Button onClick={clickFn} />);
    fireEvent.click(container.firstChild);
    expect(clickFn).toBeCalledTimes(1);
  });
});
import React from 'react';
import classNames from 'classnames';
import './index.scss';
import { ButtonProps } from './type';
const Button: React.FC<ButtonProps> = (props) => {
  const {
    children,
    className,
    style,
    type = 'primary',
    variant = 'base',
    active = false,
    disabled = false,
    size = 'medium',
    shape = 'round',
    onClick = () => { },
    ...buttonProps
  } = props;
  return (
    <button
      className={classNames(
        'i-button',
        `i-button--type-${type}`,
        `i-button--variant-${variant}`,
        `i-button--size-${size}`,
        `i-button--shape-${shape}`,
        active && 'i-button-active',
        disabled && 'i-button-disabled',
        className,
      )}
      style={{ ...style }}
      disabled={disabled}
      onClick={onClick}
      {...buttonProps}
    >
      {children}
    </button>
  );
};
Button.displayName = 'Button';
export default Button;
import React from 'react';
export interface ButtonProps {
  /**
   * 内容
   */
  children?: React.ReactNode;
  /**
   * 类名
   */
  className?: string;
  /**
   * 自定义样式
   */
  style?: React.CSSProperties;
  /**
   * 按钮类型,用于描述组件不同的应用场景
   * @default primary
   */
  type?: 'info' | 'primary' | 'error' | 'warning' | 'success';
  /**
   * 按钮形式
   * @default base
   */
  variant?: 'base' | 'outline' | 'dashed' | 'text';
  /**
   * 是否聚焦状态
   * @default false
   */
  active?: boolean;
  /**
   * 是否禁用按钮
   * @default false
   */
  disabled?: boolean;
  /**
   * 按钮尺寸
   * @default medium
   */
  size?: 'small' | 'medium' | 'large';
  /**
   * 按钮形状
   * @default round
   */
  shape?: 'square' | 'round' | 'circle';
  /**
   * 点击按钮触发事件
   */
  onClick?: React.MouseEventHandler;
}
运行 yarn test:

三、常见的测试用例
1、生成测试快照
import { render } from '@testing-library/react';
import Button from '../index';
describe('Button 组件测试', () => {
  it('create', () => {
    const { asFragment } = render(<Button icon="ThePlus" />);
    expect(asFragment()).toMatchSnapshot();
  });
});
2、校验查询到的子元素
可通过 React Testing Libraryㅤ的 queryByText 这类查询函数查找到需要校验的元素,提供 jest-dom 提供的 toBeInTheDocument 可用来判断是否在文档中:
import { render } from '@testing-library/react';
import Xx from '../index';
describe('Xx 组件测试', () => {
  // 匹配指定文本是否在节点中
  it('info', () => {
    const { queryByText } = render(<Xx info="加载中" />);
    expect(queryByText('加载中')).toBeInTheDocument();
  })
  // 匹配指定类名的节点
  it('maxLength', () => {
    const { container } = render(<Xx maxLength={15} />);
    expect(container.querySelector('.i-input--limit')).toBeInTheDocument();
  })
  // 匹配指定 alt 的节点
  it('image alt', () => {
    const { getByAltText } = render(<Xx image='https://picsum.photos/180/120' alt="test-xx" />);
    expect(getByAltText('test-xx').src).toBe(imageSrc);
  });
  // 匹配指定 test-id 的节点
  it('maxWidth', () => {
    const { getByTestId } = render(
      <Xx>
        <Xx.Item>item1</Xx.Item>
        <Xx.Item maxWidth={80} data-testid='test-item'>item2</Xx.Item>
        <Xx.Item>item3</Xx.Item>
      </Xx>
    );
    expect(getByTestId('test-item')).toHaveStyle('max-width: 80px;');
  });
});
还可以套用一层 firstChild、lastChild 的封装来简化同一类匹配方式的代码:
import { render } from '@testing-library/react';
import Badge from '../index';
describe('Badge 组件测试', () => {
  function renderSup(badge) {
    const { container } = render(badge);
    return container.lastChild;
  }
  it('count', () => {
    expect(renderSup(<Badge />)).toHaveTextContent('0');
    expect(renderSup(<Badge count='new' />)).toHaveTextContent('new');
  });
});
3、根据传入属性校验类名
import { render } from '@testing-library/react';
import Xx from '../index';
describe('Xx 组件测试', () => {
  it('shape', () => {
    const { container } = render(<Xx shape="round">L</Xx>);
    expect(container.firstChild).toHaveClass('i-xx__shape-round');
  })
});
4、根据传入属性校验样式
import { render } from '@testing-library/react';
import Xx from '../index';
describe('Xx 组件测试', () => {
  it('size', () => {
    const { container } = render(<Xx size={24}>L</Xx>);
    expect(container.firstChild).toHaveStyle('width: 24px');
  })
});
5、测试传入事件是否生效
通过 Jest 的 Mock 函数来确保回调函数如期调用:
import { render, fireEvent } from '@testing-library/react';
import Button from '../index';
describe('Button 组件测试', () => {
  it('onClick', () => {
    const clickFn = jest.fn();
    const { container } = render(<Button onClick={clickFn} />);
    fireEvent.click(container.firstChild);
    expect(clickFn).toBeCalledTimes(1);
    // 或
    // expect(clickFn).toHaveBeenCalled();
  });
});
6、测试禁用事件是否生效
import { render, fireEvent } from '@testing-library/react';
import Button from '../index';
describe('Button 组件测试', () => {
  it('disabled', () => {
    const clickFn = jest.fn();
    const { container } = render(<Button disabled onClick={clickFn} />);
    expect(container.firstChild).toBeDisabled();
    fireEvent.click(container.firstChild);
    expect(clickFn).toBeCalledTimes(0);
  });
});
7、测试模拟键鼠操作生效
通过 userEvent 来模拟用户操作,测试事件是否生效:
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Input from '../index';
describe('Input 组件测试', () => {
  it('keyDown', async () => {
    const onKeydownFn = jest.fn();
    const onEnterFn = jest.fn();
    const { container } = render(
      <Input onKeyDown={onKeydownFn} onEnter={onEnterFn} />,
    );
    const InputDOM = container.firstChild.firstChild;
    const user = userEvent.setup()
    await user.click(InputDOM)
    await user.keyboard('abc{enter}')
    expect(onEnterFn).toBeCalled();
    expect(onKeydownFn).toBeCalled();
  });
});
更多测试用例可参考 iDesign React 代码。