Jest + React Testing Library 单测总结

Jest + React Testing Library 单测总结已关闭评论

1、背景

以前还是学生的时候,有学习一门与测试相关的课程。那个时候,觉得测试就是写 test case,写断言,跑测试,以及查看 test case 的 coverage。整个流程和写法也不是特别难,所以就理所当然地觉得,写测试也不是特别难。

加上之前实际的工作中,也没有太多的写测试的经历,所以当自己需要对组件库补充单元测试的时候,发现并不能照葫芦画瓢来写单测。一时不知道该如何下手,也不知道如何编写有效的单测,人有点懵,于是就比较粗略地研究了一下前端组件单测。

1.1 单测的目的

  • 在频繁的需求变动中可控地保障代码变动的影响范围
  • 提升代码质量和开发测试效率
  • 保证代码的整洁清晰
  • ……

总之单测是一个保证产品质量的非常强大的手段。

1.2 测试框架和 UI 组件测试工具

而说起前端的测试框架和工具,比较主流的 JavaScript 测试框架有 Jest、Jasmine、Mocha 等等,并且还有一些 UI 组件测试工具,比如 testing-libraray,enzyme 等等。

测试框架和 UI 组件测试工具之间并不是相互依赖、非此即彼的,而是可以根据不同工具的性质做不同的搭配。目前腾讯课堂基于 Tdesign 开发的素材库组件的单测,就是使用 Jest + React Testing Library 来完成。

1.3 组件单测须知

在开始进行组件单测的时候,有几个因素我们需要考虑:

  • 组件是否按照既定的条件 / 逻辑进行渲染
  • 组件的事件回调是否正确
  • 异步接口如何校验
  • 异步执行完毕后的操作如何校验
  • ……

当然不止这些列举出来的,根据不同的业务场景,我们考虑的因素需要更全面更细致。

2、Jest 的使用

Jest 的安装这里就不赘述了,如果使用 create-react-app 来创建项目,Jest 和 React Testing Library(RTL) 都已经默认安装了。

如果想要看如何安装 Jest,可以参考:Jest 上手

Jest 常用的配置项在根目录中的 jest.config.js 中,常用的配置可以参考:Jest 配置文件

2.1 Jest 基础 API

Jest 的最基础,最常用的三个 API 是:describe、test 和 expect。

  • describe 是 test suite(测试套件)

  • test (也可以写成 it) 是 test case(测试用例)

  • expect 是断言

import aFunction from'./function.js';

// 假设 aFunction 读取一个 bool 参数,并返回该 bool 参数
describe('a example test suite', () => {
 test('function return true', () => {
   expect(aFunction(true)).toBe(true);
   // 测试通过
 });
 test('function return false', () => {
   expect(aFunction(false)).toBe(false);
   // 测试通过
 });
});

通过运行 npm run jest (运行所有的 test suite 和 test case,以及断言),或者 npm run jest -t somefile.test.tsx(运行指定文件中的测试用例),就可以得到测试结果,如:

当然,如果想要看到覆盖率的报告,可以使用 jest –coverage,或者 jest-report

在 VS Code 中,我们也可以安装插件:Jest Runner

在代码中,就可以快速跑测试用例,可以说非常的方便了。

如果在使用 Jest runner 的时候出现 Node.js 相关的报错,可以查看一下当前 Node.js 的使用版本,切换到 14.17.0 版本即可。

2.2 Jest 匹配器

Jest 匹配器是在 expect 断言时,用来检查值是否满足一定的条件。例如上面的例子中:

expect(aFunction(true)).toBe(true)

其中 toBe () 就是用来比较 aFunction (true) 的值是否为 true。

完整的 Jest 匹配器可以在 这里 查看,下面也列举一些常用的匹配器:

匹配器说明
.toBe(value)相等性,检查规则为 === + Object.is
.toEqual(value)相等性,递归对比对象字段
.toBeInstanceOf(Class)检查是否属于某一个 Class 的 instance
.toHaveProperty(keyPath, value)检查断言中的对象是否包含 keyPath 字段,或可以检查该对象的值是否等于 value
.toBeGreaterThan(number)大于 number
.toBeGreaterThanOrEqual(number)大于等于 number
.toBeNaN()值是否是 NaN
.toMatch(regexp or String)字符串的相等性,可以填入 string 或者一个正则
.toContain(item)substring
.toHaveLength(number)字符串长度

其实在 Testing Library 库中,还提供了一些匹配器专门用来测试前端组件,这些扩展的匹配器会让前端组件的测试变得更灵活。除了前端组件的匹配器,一些扩展库也依据不同的测试场景衍生出了很多其他的匹配器。

2.3 Jest Mock

在查看官方文档的时候,Jest 匹配器中还有一类匹配器专门用来检查 Jest Mock 函数的。在组件单测中,有的时候我们可能只关注一个函数是否被正确地调用了,或者只想要某个函数的返回值来支持该组件渲染逻辑是否正确,而并不关心这个函数本身的逻辑。正如官方文档中强调的那样:

Testing Library encourages you to avoid testing implementation details like the internals of a component you’re testing.

测试库鼓励您避免测试实现细节,例如您正在测试的组件的内部结构。

所以,Jest Mock 的意义就在于可以帮助我们完成下面这些事情:

  1. 有些模块可能在测试环境中不能很好地工作,或者对测试本身不是很重要,使用虚拟数据来 mock 这些模块,可以使你为代码编写测试变得更容易;

  2. 如果不想在测试中加载这个组件,我们可以将依赖 mock 到一个虚拟组件;

  3. 测试组件处于不同状态下的表现;

  4. mock 一些子组件,可以帮助减小快照的大小,并使它们在代码评审中保持可读性;

  5. ……

Jest Mock 的常用 API 是:jest.fn () 和 jest.mock ()。

2.3.1 jest.fn()

通过 jest.fn(implementation) 可以创建 mock 函数。如果没有定义函数内部的实现,mock 函数会返回 undefined。

// 定义一个 mock 的函数,因为没有函数体,所以 mockFn 会 return undefined
const mockFn = jest.fn();

// mockFn 调用
mockFn();
// 虽然没有定义函数体,但是 mockFn 被调用过了
expect(mockFn).toHaveBeenCalled();

const res = mockFn('a','b','c');

// 断言 mockFn 的执行后返回 undefined
expect(res).toBeUndefined();

// 断言mockFn被调用了两次
expect(mockFn).toBeCalledTimes(2);

// 断言mockFn传入的参数为a,b,c
expect(mockFn).toHaveBeenCalledWith('a','b','c');

// 定义implementation,自定义函数体:
const returnsTrue = jest.fn(() =>true); // 定义了函数体
console.log(returnsTrue()); // true

// 可以给mock的函数设置返回值
const returnSomething = jest.fn().mockReturnValue('hello world');
expect(returnSomething()).toBe('hello world');

// mock也可以返回一个Promise
const promiseFn = jest.fn().mockResolvedValue('hello promise');
const promiseRes = await promiseFn();
expect(promiseRes).toBe('hello promise');

2.3.2 jest.mock(moduleName, factory, options)

jest.mock() 可以帮助我们去 mock 一些 ajax 请求,作为前端只需要去确认这个异步请求发送成功就好了,至于后端接口返回什么内容我们就不关注了,这是后端自动化测试要做的事情。

// users.js 获取所有user信息
import axios from'axios';

class Users {
 staticall() {
   return axios.get('.../users.json').then(resp => resp.data);
 }
}

exportdefault Users;
// user.test.js
import axios from'axios';
import Users from'./users';

jest.mock('axios');

test('should fetch users', () => {
 const users = [{name: 'Bob'}];
 const resp = {data: users};
 axios.get.mockResolvedValue(resp);
 
 // or you could use the following depending on your use case:
 // axios.get.mockImplementation(() => Promise.resolve(resp))
 
 return Users.all().then(data => expect(data).toEqual(users));
});

2.3.3 Jest Mock 的匹配器

Jest 匹配器中还有一类匹配器专门用来检查 jest mock() 的,比如:

  • 名字
    • mockFn.mockName(value)
    • mockFn.getMockName()
  • 运行情况
    • mockFn.mock.calls:传的参数
    • mockFn.mock.results:得到的返回值
    • mockFn.mock.instances:mock 包装器实例
  • 模拟函数
    • mockFn.mockImplementation(fn):重新声明被 mock 的函数
    • mockFn.mockImplementationOnce(fn)
  • 模拟结果
    • mockFn.mockReturnThis()
    • mockFn.mockReturnValue(value)
    • mockFn.mockReturnValueOnce(value)
    • mockFn.mockResolvedValue(value)
    • mockFn.mockResolvedValueOnce(value)
    • mockFn.mockRejectedValue(value)
    • mockFn.mockRejectedValueOnce(value)

2.4 Jest 的扩展阅读材料

3、React Testing Library

testing library 是一个测试 React 组件的测试库,它的核心理念就是:

The more your tests resemble the way your software is used, the more confidence they can give you.

测试越类似于软件使用方式,就越能给测试信心。

3.1 render & debug

在测试用例中渲染内容,可以使用 RTL 库中的 render,render 函数可以为我们在测试用例中渲染 React 组件。

被渲染的组件,可以通过 debug 函数或者 screen 的 debug 函数在控制台输出组件的 HTML 结构。例如下面的 Dropdown 组件的例子:

import { render, screen } from '@testing-library/react';
import Dropdown from '../index'; // 要测试的组件

describe('dropdown test', () => {
 it('render Dropdown', () => {
   // 渲染 Dropdown 组件
   const comp = render(<Dropdown />);
   comp.debug();
   screen.debug();
   // 这两种都可以打印出来渲染组件的结构
   });
});

其实,在我们编写组件测试用例时,都可以通过 debug 函数把组件渲染结果打印出来,这可以提高我们编写用例时的效率,同时,这一特点也很符合 RTL 的设计观念。

3.2 screen

在上面的例子中,其实我们也使用到了库中的 screen。screen 为测试用例提供了一个全局 DOM 环境,通过这个环境,我们就可以去使用库中提供的不同函数去定位元素,定位后的元素可以用于断言判断或者用户交互。

3.3 定位元素

3.3.1 Query 类型

定位元素的方法在 RTL 中称为 Query,Query 帮助我们去找到页面上的元素。RTL 提供了三种 Query 的类型:”get”, “find”, “query”。

Query 类型未找到元素找到 1 个元素找到多个元素Retry (Async/Await)
Single Element



getBy…Throw errorReturn elementThrow errorNo
queryBy…Return nullReturn elementThrow errorNo
findBy…Throw errorReturn elementThrow errorYes
Multiple Elements



getAllBy…Throw errorReturn arrayReturn arrayNo
queryAllBy…Return []Return arrayReturn arrayNo
findAllBy…Throw errorReturn arrayReturn arrayYes

从上面的表格可以看出来,定位的方法在找单个元素时和多个元素时会做了一些区别,比如 getBy… 如果找到了多个元素就会 throw error,这时就需要使用 getAllBy…。

get 和 query 的区别主要是在未找到元素时,queryBy 会返回 null,这对于我们测试一个元素是否存在时非常有帮助。

而 findby 的作用主要用于那些最终会显示在页面当中的异步元素。

3.3.2 Query 内容

那么,getBy…、queryBy… 和 findBy… 后面具体可以查询什么内容呢?

  • 主要

    • ByLabelText:用于表单的 label

    • ByPlaceholderText:用于表单

    • ByText:查询 TextNode

    • ByDisplayValue:输入框等当前值

  • 语义

    • ByAltText:img 的 alt 属性

    • ByTitle:title 属性或元素

    • ByRole:ARIA role,可以定位到辅助树中的元素

  • Id

    • getByTestId:函数需要在源代码中添加 data-testid 属性才能使用

一般而言,getByText 和 getByRole 应该是元素的首选定位类型。

import { render, screen } from'@testing-library/react';
import Dropdown from'../index'; // 要测试的组件

const propsRender = {
 commonStyle: {},
 data: {
   btnTheme: 'default',
   btnVariant: 'text',
   btnText: 'test', // 给 dropdown 的 button 设置文字 'test'
   trigger: 'click',
 },
 style: {},
 meta: {
   previewMode: true,
   isEditor: false
 },
 on: jest.fn(),
 off: jest.fn(),
 emit: jest.fn(),
};

describe('dropdown test', () => {
 it('render Dropdown', () => {
   // 渲染 Dropdown 组件
   const comp = render(<Dropdown />);
   // 使用 queryByText("test") 定位这个 button 的文字内容,然后使用断言+匹配做测试
   expect(screen.queryByText("test")).toBeInTheDocument();
 });
});

findBy 的使用方法

假如在 Component 组件中定义一行文字 “hello world” 和一个定时器,在组件渲染 3 秒后再显示这行字。

describe('test hello world', () => {
 test('renders component', async () => {
   render(<Component />);

   // 在组件的初始化渲染中,我们在 HTML 中无法通过 queryBy 找到 “hello world”,因为它三秒后才能出现
   expect(screen.queryByText(/hello world/)).toBeNull();
   
   // await 一个新的元素被找到,并且最终确实被找到当 promise resolves 并且组件重新渲染之后。
   expect(await screen.findByText(/hello world/)).toBeInTheDocument();
 });
});

对于任何开始不显示、但迟早会显示的元素,要使用 findBy。如果你想要验证一个元素不在页面中,使用 queryBy,否则默认使用 getBy。

RTL 所有定位方法可 点击 查看。

3.4 RTL + Jest 匹配器

在 2.2 Jest 匹配器 中可以看到 Jest 提供了一些匹配器,然而 Jest 自己提供的匹配器很难去实现组件测试的一些特殊条件,所以 RTL 自己实现了一个 Jest 匹配器的扩展包:jest-dom

3.5 事件:FireEvent

实际的用户交互可以通过 RTL 的 fireEvent 函数去模拟。

fireEvent(node: HTMLElement, event: Event)
fireEvent[eventName](node: HTMLElement, eventProperties: Object)

// <button>Submit</button>
fireEvent(
 getByText(container, 'Submit'),
 new MouseEvent('click', {
   bubbles: true,
   cancelable: true,
 }),
);

// 两种写法
fireEvent(element, new MouseEvent('click', options?));
fireEvent.click(element, options?);

fireEvent 函数需要两个参数,一个参数是定位的元素 node,另一个参数是 event。这个例子中就模拟了用户点击了 button,同时 fireEvent 有两种写法。

事件 options 描述

属性 / 方法描述
bubbles返回特定事件是否为冒泡事件。
cancelBubble设置或返回事件是否应该向上层级进行传播。
cancelable返回事件是否可以阻止其默认操作。
composed指示该事件是否可以从 Shadow DOM 传递到一般的 DOM。
composedPath()返回事件的路径。
createEvent()创建新事件。
currentTarget返回其事件侦听器触发事件的元素。
defaultPrevented返回是否为事件调用 preventDefault () 方法。
eventPhase返回当前正在评估事件流处于哪个阶段。
isTrusted返回事件是否受信任。
target返回触发事件的元素。
timeStamp返回创建事件的时间(相对于纪元的毫秒数)。
type返回事件名称。

常用 fireEvent:

键盘:

  • keyDown

  • keyPress

  • keyUp

聚焦:
  • focus

  • blur

表单:
  • change

  • input

  • invalid

  • submit

  • reset

鼠标:
  • click

  • dblClick

  • drag

fireEvent API 列表可 点击 查看。

4、写在最后

测试在整个需求开发的流程中起着重要作用,它对于需求产品的质量提供了强而有力的保障。但是在实际的工作中,产品的迭代、需求的变更以及各种不确定的因素,我们经常会陷入“bug的轮回” —— 关上一个bug,点亮另一个bug。

随着业务复杂度的提升,测试的人力成本也会越来越高。面对这些痛点,作为“懒而聪明”的前端开发,我也常常在思考有什么方法可以在解放双(ren)手(li)的同时,又能保证产品的质量,也不必在每次需求上线时紧张兮兮地盯着告警看板,生怕发的版本影响了其他的功能。所以,我相信借助于测试的力量,这些痛点终有一天会逐个击破。

就像开头提到的,本文只是“比较粗略”地浏览了 Jest + RTL,相较于整个前端单测来说只是冰山一角。希望在日后工作的每一天能不断地探索这个领域,也希望在不久的将来,我也能 “快乐编码,自信发布”。





紧追技术前沿,深挖专业领域
扫码关注我们吧!
关注我们,将为你带来最前沿的前端资讯。

来源: 印记中文