2022年了,如何测试你的React组件

1,916 阅读8分钟

前端为什么要单测

前端开发和所有其他的软件开发是一样的,为了保障项目交付时代码的质量,需要通过一些测试用例来保证代码在所有的边界情况下都能正常运行。

我们不需要使用测试驱动开发这种极端的模式,但是如果在一开始就有书写测试用例的意识和习惯,就能保证我们更规范的去书写代码的逻辑。

方案选择

对于React的单测方案,可能很多人下意识的会选择 Jest + Enzyme 的方案,Jest 是 Facebook 开源的测试框架,而 Enzyme 是Airbnb 开源的React的测试工具库。他们带有大厂的光环,一度也是React组件首选的测试方案。

但是Airbnb 已经退出Enzyme,现在它已经变成个人项目。并且Enzyme 使用了很多React的内部函数,对于hooks的支持也不是很友好,并且issues的解决速度也让人担忧,所以Enzyme现在已经不是测试React的首选工具库了。

React Testing Library

最近几年,RTL受到越来越多的关注,并且被越来越多的公司和开源项目采纳,CRA已经内置了RTL。

RTL更多的关注使用者怎么去使用一个React组件,以及组件在页面中具体的DOM表现。对于一个React组件的测试,开发者不用再去关心React的内部实现,只要根据组件的真实使用场景去书写对应的单测就可以了,大大减少了单测用例的书写难度。

它没有像Enzyme提供的过多API和复杂的概念,它封装了常用的DOM操作,以及一些常用的断言方法,可以和Jest进行很好的结合。

开始测试

我们有一个组件Input,在antd Input组件的基础上,增加了一个预览态的功能

快照

很多情况下,我们可以通过组件的快照来保证组件渲染的唯一性,当一个组件的dom结构改变时,可以通过快照对比快速的反应出来

import React from 'react';
import { render, asFragment } from '@testing-library/react';
import Input from './input';

test('input snapshot', () => {
    const { container } = render(<Input />);
    expect(container.firstChild).toMatchSnapshot();
    
    //expect(asFragment()).toMatchSnapshot();
});

通过上面的这个例子,我们引出了render方法。这个方法是RTL的一个核心方法,用来渲染需要测试的React组件。其中返回的container用一个div包裹了包组件真实的dom结构,这个对象也可以访问querySelector等原生的dom方法。

通过toMatchSnapshot这个方法,在运行单测的时候,会自动生成组件的.snap文件,如果dom结构改变,在再次运行的时候会报错,需要通过-u的命令参数来强制更新快照文件。

也可以使用asFragment来生成快照。

一个简单的用例

对于组件的测试,我们可以从组件最终的渲染结果来入手

import React from 'react';
import { render, screen } from '@testing-library/react';
import Input from './input';

test('input preview', () => {
    render(<Input isPreview value="input preview" />);

    // screen.debug();

    expect(screen.getByText('input preview')).toBeInTheDocument();
});

上面的测试用例,测试了Input组件的预览状态。在我们的预期中,页面最终使用一个span标签来渲染这个组件,标签的内容是字符串 input preview

这个测试用例也引入了另一个重要的api,就是 screen。对于screen,可以把它理解成组件渲染成的dom对象,并且内置查询方法。

其中screen.debug这个方法可以将组件渲染的dom结构打印在终端上,方便我们查看具体的节点信息,提高书写用例的效率。

上面的getByText方法会按照传进去的文本去匹配组件渲染的内容,ByText是RTL内置的几种查询类型之一

  • ByLabelText: 按照label标签或者aria-label的文本内容进行查找
  • ByPlaceholderText: 按照Input的placeholder内容进行查找
  • ByText: 按照元素文本内容查找
  • ByDisplayValue: 按照表单内容当前值进行查找
  • ByAltText: 按照img标签的alt属性值进行查找
  • ByTitle: 按照title属性或者svg元素的title标签进行查找
  • ByRole: 按照aria角色进行查找
  • ByTestId: 按照data-itestid属性进行查找

如果对于复杂的组件上述方法无法进行覆盖,也可以使用container这个对象,去进行更精细的dom结构的操作。

toBeInTheDocument是一个内置的断言,用来判断内容是否被正确的渲染在document中。除了这个断言,还内置了如下的断言函数:

  • toBeDisabled
  • toBeVisible
  • toContainElement
  • toHaveAttribute
  • toHaveClass
  • toHaveTextContent
  • toHaveValue
  • toBeChecked
  • ...

从命名可以看出,这些断言都是和DOM相关的,对于一些非DOM相关的断言,可以使用Jest提供的断言函数,比如 toBetoBeNull 等。

触发事件

在测试Input组件的时候,我们还希望能模拟用户正常的输入操作,并且判断通过props传进去的自定义onChange方法是否被触发

import React from 'react';
import { render, screen } from '@testing-library/react';
import Input from './input';

test('input change', () => {
    const onChange = jest.fn();
    render(<Input onChange={onChange} />);

    fireEvent.change(screen.getByRole('textbox'), {
        target: { value: 'one' },
    });

    expect(screen.getByRole('textbox')).toHaveValue('one');
    expect(onChange).toHaveBeenCalledTimes(1);
});

fireEvent 是另一个核心的api,用来手动触发各种dom事件。通过fireEvent(node, event)来进行事件的触发,node是要触发事件的dom元素,event是事件需要接受的参数变量。为了方便操作,fireEvent内置了很多常用的helper函数,比如changeclickfocus,具体可以查看event helpers

上面的用例中,找到输入框元素,并触发change事件,其中输入的值为字符串one。在事件触发结束后,通过toHaveValue这个断言来判断输入框的值是不是one。

可以通过jest.fn()来模拟一个函数,这个函数如果被执行了,会记录一系列的数据用于断言处理。我们通过它来模拟一个onChange函数,在Input的组件逻辑中,如果props中有onChange,就会在触发完change事件后去调用onChange方法。用例中用fireEvent触发了一次change事件,那么就可以通过toHaveBeenCalledTimes这个断言,来判断onChange是否被执行过。

异步渲染

我们有一个Cascader,它对antd 的Cascader进行了增加,支持dataSource属性,它支持一个异步函数来渲染异步返回的数据。我们将通过下面的用例对这个组件进行测试

import React from 'react';
import { render, screen } from '@testing-library/react';
import { Cascader } from '../src/cascader/index';

const options = [
    {
        value: 'zhejiang',
        label: '浙江',
        children: [
            {
                value: 'hangzhou',
                label: '杭州',
                children: [
                    {
                        value: 'xihu',
                        label: '西湖',
                    },
                ],
            },
        ],
    },
    {
        value: 'jiangsu',
        label: '江苏',
        children: [
            {
                value: 'nanjing',
                label: '南京',
                children: [
                    {
                        value: 'zhonghuamen',
                        label: '中华门',
                    },
                ],
            },
        ],
    },
];

const fetchData = () => {
    return new Promise<any>((resolve) => {
        setTimeout(() => {
            resolve(options);
        }, 500);
    });
};

test('async dataSource', async () => {
    render(<Cascader dataSource={fetchData} value={['zhejiang', 'hangzhou', 'xihu']} />);

    expect(await screen.findByText('浙江 / 杭州 / 西湖')).toBeInTheDocument()
});

因为fetchData是一个异步函数,所以test case需要用async进行包裹。在组件render以后,因为异步函数的存在,所以在用screen查询dom节点的时候,需要使用await进行处理。

在这里使用findByText来查找内容,而不是使用getByText。在RTL中,有三种类型的查询方法,分别是getBy、findBy和queryBy。这个三个类型的方法用于查询单个匹配,如果需要查询多个匹配的结果,需要使用对应的三个方法getAllBy、findAllBy和queryAllBy,他们之间存在如下的差别。

没有匹配匹配一个匹配多个是否支持await
getBy抛出一个错误正确返回抛出一个错误不支持
findBy抛出一个错正确返回抛出一个错误支持
queryBy返回null正确返回抛出一个错误不支持
getAllBy抛出一个错返回数组返回数组不支持
findAllBy抛出一个错返回数组返回数组支持
queryAllBy返回空数组返回数组返回数组不支持

从上面的表格中可以看出,如果是异步渲染的dom结构,需要使用findBy和findAllBy来进行匹配。而getBy和queryBy的区别,主要在匹配失败的时候的表现。如果getBy匹配失败,则直接抛出一个错误,剩下的测试被中断;而queryBy匹配失败的时候,则是返回null,在断言正确的情况下,剩下的测试用例可以正常的运行完。

测试hooks

为了解决Select、GroupRadio、Checkbox等组件异步数据的渲染,我们写了一个hook来统一处理异步的逻辑

const toObjectArray = (array) => {
  return array.map((value) => {
    if (typeof value === 'string') {
      return {
        label: value,
        value: value,
      } as DataSourceItemType;
    }
    return value;
  });
};

export default function useDataSource(
  dataSource,
  options,
) {
  const [optionList, setOptionList] = useState([]);

  useEffect(() => {
    if (typeof dataSource === 'function') {
      Promise.resolve(dataSource())
        .then((res) => {
          setOptionList(toObjectArray(res));
        })
        .catch((error) => {
          console.error(error);
        });
    } else {
      if (dataSource) {
        setOptionList(toObjectArray(dataSource));
      } else if (options) {
        setOptionList(toObjectArray(options));
      } else {
        setOptionList([]);
      }
    }
  }, [dataSource, options]);

  return [optionList, setOptionList];
}

对于hooks,我们使用@testing-library/react-hooks来进行测试。

import { renderHook, act } from '@testing-library/react-hooks';
import useDataSource from './hooks';

const options = [
    {
        label: 'apple',
        value: 1
    },
    {
        label: 'orange',
        value: 2
    },
];

const dataSource = () => {
    return new Promise<any>((resolve) => {
        setTimeout(() => {
            resolve(options);
        }, 500);
    });
};

test('dataSource as List', () => {
    const dataSource = [
        {
            label: 'cherry',
            value: 3
        },
        {
            label: 'banana',
            value: 4
        },
    ];

    const { result } = renderHook(() => useDataSource(dataSource));

    const optionList = result.current[0];

    expect(optionList[0].value).toEqual(3);
});

test('dataSource as empty', () => {

    const { result } = renderHook(() => useDataSource(undefined, options);

    const optionList = result.current[0];

    expect(optionList[0].value).toEqual(1);
    expect(optionList[1].label).toEqual('orange');
});

test('dataSource as sync function', async () => {
    const { result, waitForNextUpdate } = renderHook(() => useDataSource(dataSource))

    await waitForNextUpdate()

    expect(result.current[0].length).toBe(2)
});

使用renderHook来执行hook,函数返回对象的result字段保存了hook的执行结果,通过result.current可以拿到具体的数据。

如果dataSource是一个异步函数,那么hook返回的数据就需要异步渲染。这里我们可以使用waitForNextUpdate这个方法,等到异步函数执行结束后再获取数据进行测试。

总结

本文通过几个测试用例,简单介绍了如何使用RTL进行React组件的测试,对于一些更复杂的场景没有进行太多的涉及。

引用 react-testing-library React Testing Library Tutorial React Hooks Testing Library