Jest + React Testing Library 单测总结

2022-06-29 15:07:26 浏览数 (1)

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 是断言
代码语言:javascript复制
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 断言时,用来检查值是否满足一定的条件。例如上面的例子中:

代码语言:javascript复制
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。

代码语言:javascript复制
// 定义一个 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 请求,作为前端只需要去确认这个异步请求发送成功就好了,至于后端接口返回什么内容我们就不关注了,这是后端自动化测试要做的事情。

代码语言:javascript复制
// users.js 获取所有user信息
import axios from'axios';

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

exportdefault Users;
代码语言:javascript复制
// 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 的扩展阅读材料

  • Jest 学习指南
  • 那些年错过的 React 组件单元测试
  • 使用 Jest 测试 JavaScript (Mock 篇)

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 组件的例子:

代码语言:javascript复制
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 error

Return element

Throw error

No

queryBy...

Return null

Return element

Throw error

No

findBy...

Throw error

Return element

Throw error

Yes

Multiple Elements

getAllBy...

Throw error

Return array

Return array

No

queryAllBy...

Return []

Return array

Return array

No

findAllBy...

Throw error

Return array

Return array

Yes

从上面的表格可以看出来,定位的方法在找单个元素时和多个元素时会做了一些区别,比如 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 应该是元素的首选定位类型。

代码语言:javascript复制
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 秒后再显示这行字。

代码语言:javascript复制
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

  • Custom matchers
    • toBeDisabled
    • toBeEnabled
    • toBeEmptyDOMElement
    • toBeInTheDocument
    • toBeInvalid
    • toBeRequired
    • toBeValid
    • toBeVisible
    • toContainElement
    • toContainHTML
    • toHaveAccessibleDescription
    • toHaveAccessibleName
    • toHaveAttribute
    • toHaveClass
    • toHaveFocus
    • toHaveFormValues
    • toHaveStyle
    • toHaveTextContent
    • toHaveValue
    • toHaveDisplayValue
    • toBeChecked
    • toBePartiallyChecked
    • toHaveErrorMessage
  • Deprecated matchers
    • toBeEmpty
    • toBeInTheDOM
    • toHaveDescription

3.5 事件:FireEvent

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

代码语言:javascript复制
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,相较于整个前端单测来说只是冰山一角。希望在日后工作的每一天能不断地探索这个领域,也希望在不久的将来,我也能 “快乐编码,自信发布”。

紧追技术前沿,深挖专业领域

扫码关注我们吧!

0 人点赞