0%

React测试指南

React测试指南

提纲:

  • 前言
    • 为什么要自动化测试
    • 测什么
    • 前端测试最佳实践
  • React主要测试方案优缺点
  • 关于快照测试(Snapshot testing)
  • 选择要测试的DOM元素
  • React组件主要测试内容

前言

为什么要自动化测试

  • 自动化测试对于很少使用的功能特别有用,因为我们常常会遗忘这些功能的测试
  • 使我们更改代码更有信心
  • 有文档的作用,并且更新及时
  • 通过给bug写测试用例,可以对回归的bug进行预防

测什么

  1. 两种测试模型 —— 金字塔模型 & 奖杯模型

    金字塔模型 表示的是UI测试是编写最慢,最昂贵的,而单元测试是编写最快,最便宜的,因此我们应该多编写单元测试而少进行UI测试。其对后端通常工作得很好,但前端UI细节变更很频繁,这会导致许多单元测试失败 —— 可能我们花了很多时间更新单元测试,但是对于大的功能模块是否仍然有效却没有足够的信心。
    奖杯模型 在前端测试中则越来越受欢迎,其中的集成测试(Integration)可以为你提供最大的投资回报

  2. 分层说明

  • Unit tests 是测试单个代码单元,例如函数、React组件、棘手的算法。你不需要浏览器或数据库即可运行单元测试,因此它们非常快速。工具: Jest.
  • Service tests 它们测试多个单元的集成,但没有任何UI。
  • UI tests 会测试在真实浏览器(通常带有真实数据库)中加载的整个应用程序。这是确保应用程序的所有部分协同工作的唯一方法,但是它们运行缓慢,编写棘手且经常出错。
  • Static 静态分析捕获语法错误,不好的写法,和不正确的API。工具:—代码格式化程序,像 Prettier;—代码检测工具,像ESLint;—类型检查器,像TypeScript or Flow
  • Integration tests 给你应用程序的所有功能都能按预期运行的信心。工具: Jest & Enzyme or react-testing-library.
  • End-to-end tests 确保你的应用程序可以整体使用:前端,后端和数据库以及其他所有功能。工具: Cypress.
  1. Unit tests & Integration tests 比较
    Unit tests Integration tests
    一个测试只覆盖一个模块 一个测试涵盖整个功能特性或一个页面
    重构后通常需要重写 大部分时候都能在重构中留存下来
    难以避免测试实现细节 更像用户如何使用你的应用

前端测试最佳实践

  • 编写比任何其他种类的测试更多的集成测试
  • 良好的测试可以验证外部行为是否正确,而不用知道任何实现细节
  • 好的测试是确定性的,它们不依赖于环境
  • 好的测试不会有任何 不必要的断言 & 不必要的测试用例
  • 不用争取100%的代码覆盖率(这是库 & 开源项目追求的目标),更重要的是易于维护,并让你有信心更改代码

React主要测试方案优缺点

Jest优点:

  • 很快
  • 交互式的watch mode,仅运行与你的更改相关的测试
  • 有用的失败信息
  • 简单的配置或零配置
  • Mocks & spies
  • 覆盖率报告
  • 丰富的匹配器API

React Testing Library比Enzyme具有的一些有点:

  • 更简单的API(Enzyme API面太大,你需要知道哪些方法是好的,哪些不是)
  • 方便的查询(form label, image alt, ARIA role)
  • 异步查询和实用程序
  • 更好的错误信息
  • 设置更简单
  • React团队推荐

React Testing Library 缺点:

  • 虽然说Enzyme API面很大,访问组件内部太容易,相对的React Testing Library 的API就显得更加固话
  • React Testing Library是一个新工具:它不那么成熟,并且社区比Enzyme小

关于快照测试(Snapshot testing)

快照测试听起来不错,但是有几个问题:

  • 容易在提交快照时携带bug
  • 生成快照失败很难理解
  • 一个较小的更改可能会导致数百个失败的快照
  • 我们倾向于不加思索地更新快照
  • 与底层模块耦合
  • 测试意图很难理解
  • 他们给人一种错误的安全感

避免进行快照测试,除非你以明确的意图测试非常短的输出(例如类名或错误消息),或者当你确实想验证输出是否相同时。如果你使用快照,请使其简短些,并优先使用toMatchInlineSnapshot()而不是toMatchSnapshot()

选择要测试的DOM元素

比较一下选择DOM元素的不同方法

选择器 是否推荐 备注
button, Button Never 最差:太通用
.btn.btn-large Never 不好:和styles耦合
#main Never 不好:一般避免使用ID
[data-testid=”cookButton”] Sometimes 可以:对用户不可见,但没有实现细节,请在没有更好的选择时使用
[alt=”Chuck Norris”], [role=”banner”] Often 良好:仍然对用户不可见,但已成为应用程序用户界面的一部分
[children=”Cook pizza!”] Always 最佳:应用程序用户界面的用户可见部分

React Testing Library具有用于所有良好查询的方法。查询方法有六种变体(详见这里):

No Match 1 Match 1+ Match Await?
getBy throw return throw No
findBy throw return throw Yes
queryBy null return throw No
getAllBy throw array array No
findAllBy throw array array Yes
queryAllBy [] array array No
  • getBy*() 返回第一个匹配元素,并在未找到元素或发现一个以上元素时抛出异常;
  • queryBy*()返回第一个匹配的元素,未找到元素结果为null,发现一个以上元素时抛出异常;
  • findBy*() 返回一个promise,结果为匹配元素的resolve结果 或者 如果超过默认查找时间仍未找到元素或者找到了一个以上元素时返回reject的结果
  • getAllBy*() ,queryAllBy*() ,findAllBy*():与上面相同,但返回所有找到的元素,而不仅仅是第一个。

查询方法有(详见这里):

  • getByLabelText() 通过label查找一个form元素;
  • getByPlaceholderText() 通过placeholder文本找到一个form元素;
  • getByText() 通过文本内容查找元素;
  • getByAltText() 通过alt属性查找image
  • getByTitle() 根据title属性查找元素;
  • getByDisplayValue() 根据表单元素当前value查找元素;
  • getByRole() 通过ARIA角色找到一个元素;
  • getByTestId() 通过data-testid属性查找元素;
1
<div>Hello World</div>
1
2
const { getByText } = render(<HelloWorld />);
getByText(/hello world/i);

我们可以使用正则表达式(/hello world/i)而不是字符串文字(’Hello World’),以使查询对小的调整和内容更改更具弹性。

React组件主要测试内容

  1. 渲染
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import React from 'react';
    import { render } from '@testing-library/react';
    import Pizza from '../Pizza';

    test('contains all ingredients', () => {
    const ingredients = ['bacon', 'tomato', 'mozzarella', 'pineapples'];
    const { getByText } = render(<Pizza ingredients={ingredients} />);

    ingredients.forEach(ingredient => {
    expect(getByText(ingredient)).toBeInTheDocument();
    });
    });
  2. 用户交互
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    import React from 'react';
    import { render, fireEvent } from '@testing-library/react';
    import ExpandCollapse from '../ExpandCollapse';

    test('button expands and collapses the content', () => {
    const children = 'Hello world';
    const { getByText, queryByText } = render(
    <ExpandCollapse excerpt="Information about dogs">
    {children}
    </ExpandCollapse>
    );

    // 使用queryByText() 方法而不是getByText() 方法是因为在找不到元素时前者不会抛出错误
    expect(queryByText(children)).not.toBeInTheDocument();

    fireEvent.click(getByText(/expand/i));

    expect(queryByText(children)).toBeInTheDocument();

    fireEvent.click(getByText(/collapse/i));

    expect(queryByText(children)).not.toBeInTheDocument();
    });
  3. 事件处理程序

你可以通过jest.fn() 创建一个模拟函数来检查它被调用了多少次以及使用了哪些参数等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import Login from '../Login';

test('submits username and password', () => {
const username = 'me';
const password = 'please';
const onSubmit = jest.fn();
const { getByLabelText, getByText } = render(
<Login onSubmit={onSubmit} />
);

fireEvent.change(getByLabelText(/username/i), {
target: { value: username }
});

fireEvent.change(getByLabelText(/password/i), {
target: { value: password }
});

fireEvent.click(getByText(/log in/i));

expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
username,
password
});
});
  1. 异步
    1
    2
    3
    4
    5
    6
    7
    8
    import { wait } from '@testing-library/react';

    test('something async', async () => {
    // Run an async operation...
    await wait(() => {
    expect(getByText(/done!/i)).toBeInTheDocument();
    });
    });
    但是对于查询元素,我们可以使用findBy*() 和finddBall*() 方法来等待元素出现:
    1
    2
    3
    4
    test('something async', async () => {
    expect.assertions(1);
    expect(await findByText(/done!/i)).toBeInTheDocument();
    });
    异步方法总结:
  • wait (Promise) retry the function within until it stops throwing or times out
  • waitForElement (Promise) retry the function until it returns an element or an array of elements
  • findBy and findAllBy queries are async and retry until either a timeout or if the query returns successfully; they wrap waitForElement
  • waitForDomChange (Promise) retry the function each time the DOM is changed
  • waitForElementToBeRemoved (Promise) retry the function until it no longer returns a DOM node

注意:Remember to await or .then() the result of async functions in your tests!

  1. 网络请求和mocks

有多种方法可以测试发送网络请求的组件:

a. 依赖注入;

b. 模拟服务模块;

c. 模拟高级网络API,例如fetch;

d. 模拟低级网络API,可捕获进行网络请求的所有方式。

这里没有提到将真实的网络请求发送到真实的API作为一种选择,因为它既慢又脆弱。 API返回的每个网络问题或数据更改都可能会破坏我们的测试。另外,你还需要拥有适用于所有测试用例的正确数据-很难用真正的API或数据库来实现。

这里主要介绍b、d 两个推荐方案

b: 使用jest.mock()

1
2
3
4
export const fetchIngredients = () =>
fetch(
'https://httpbin.org/anything?ingredients=bacon&ingredients=mozzarella&ingredients=pineapples'
).then(r => r.json());
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import React from 'react';
import { fetchIngredients } from '../services';

export default function RemotePizza() {
const [ingredients, setIngredients] = React.useState([]);

const handleCook = () => {
fetchIngredients().then(response => {
setIngredients(response.args.ingredients);
});
};

return (
<>
<button onClick={handleCook}>Cook</button>
{ingredients.length > 0 && (
<ul>
{ingredients.map(ingredient => (
<li key={ingredient}>{ingredient}</li>
))}
</ul>
)}
</>
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React from 'react';
import { render, fireEvent, wait } from '@testing-library/react';
import RemotePizza from '../RemotePizza';
import { fetchIngredients } from '../../services';

jest.mock('../../services');

afterEach(() => {
fetchIngredients.mockReset();
});

const ingredients = ['bacon', 'tomato', 'mozzarella', 'pineapples'];

test('download ingredients from internets', async () => {
expect.assertions(4);

fetchIngredients.mockResolvedValue({ args: { ingredients } });

const { getByText } = render(<RemotePizza />);

fireEvent.click(getByText(/cook/i));

await wait(() => {
ingredients.forEach(ingredient => {
expect(getByText(ingredient)).toBeInTheDocument();
});
});
});

我们使用Jest的mockResolvedValue方法,用模拟数据resolve 一个 Promise。(详见这里

d: 类似于模拟fetch API,但它在较低的级别上工作,因此使用其他API(如XMLHttpRequest)发送的网络请求也将被模拟。

Jest.mock() 已随Jest一起提供,模拟低级网络API这里我们安装使用的nock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import React from 'react';
import { render, fireEvent, wait } from '@testing-library/react';
import nock from 'nock';
import RemotePizza from '../RemotePizza';

const ingredients = ['bacon', 'tomato', 'mozzarella', 'pineapples'];

afterEach(() => {
nock.restore();
});

test('download ingredients from internets', async () => {
expect.assertions(5);

// query(true) 表示任何查询参数都可以通过请求,你也可以定义一个特定的查询参数来做限制,例如query({quantity:42})。
const scope = nock('https://httpbin.org')
.get('/anything')
.query(true)
.reply(200, { args: { ingredients } });

const { getByText } = render(<RemotePizza />);

fireEvent.click(getByText(/cook/i));

// 当在范围内定义的所有请求都发出时,scope.isDone() 为true。
expect(scope.isDone()).toBe(true);

await wait(() => {
ingredients.forEach(ingredient => {
expect(getByText(ingredient)).toBeInTheDocument();
});
});
});

参考:

JEST文档:https://jestjs.io/zh-Hans/

jest-dom: https://github.com/testing-library/jest-dom

Testing Library文档:https://testing-library.com/docs/intro & https://testing-library.com/docs/recipes

Test Utilities: https://zh-hans.reactjs.org/docs/test-utils.html

React 测试:https://zh-hans.reactjs.org/docs/testing.html & https://blog.sapegin.me/all/react-testing-1-best-practices/

JavaScript测试指南:http://km.oa.com/group/597/articles/show/390587 & http://km.oa.com/group/597/articles/show/390722