RN 基于 Jest + testing-library 单元测试实践

·  阅读 1508
RN 基于 Jest + testing-library 单元测试实践

技术选型

测试工具选型:Jest + testing-library

  1. jest 是一个开源的 javascript 单元测试框架,集成了测试执行器、断言库、spy、mock、snapshot和测试覆盖率报告等功能。
  2. @testing-library 是用于 DomUI 组件测试的工具,提供了一系列常用的测试 API

注意react-native-testing-library 已经转移到 @test-library/react-native

环境搭建

安装 Jest

npm i jest@27.0.2
npm i babel-jest@27.0.2		// babel进行转码
npm i ts-jest@27.0.2		// ts语法解析
npm i @types/jest		// ts语法定义
复制代码

说明

项目中如果使用的 TypeScript,需要安装 ts-jest、@types/jest,并在 tsconfig.json 文件中加入 "types": ["jest"]

注意:jest、ts-jest、babel-jest 三个依赖包的版本需要一致,否则可能会报错

安装 testing-library

npm i @testing-library/jest-dom @testing-library/react-native @testing-library/react-hooks -D
复制代码

说明

  • @testing-library/jest-dom提供了一组可用于扩展 jest 的自定义 jest 匹配器。这些将使您的测试更具声明性,更易于阅读维护。
  • @testing-library/react-native用于测试 React Native 组件。(如果测试React,请选择@testing-library/react
  • @testing-library/react-hooks为React Hook创建一个简单的测试工具,并在函数组件体内运行。可选择性下载该项。

package.json 文件配置

"scripts": {
  ...
  "test": "jest test --verbose -u --watch",
}

"jest": {
	"preset": "react-native"
}
复制代码

babel 文件配置

  • 如果项目中 bebel 配置使用的是 .babelrc.js,需要将文件转为 babel.config.js(可能会影响项目原本编译结果)
  • 如果转化为 babel.config.js 文件影响项目编译结果,可以保留 .babelrc.js 文件,然后新建 babel.config.js 文件并将 presets 声明移入 babel.config.js

示例:

// babel.config.js

module.exports = (api) => {
  api.cache.never();

  return {
    "presets": process.env.MINI_PROGRAM === 'true' ? [] : ['module:metro-react-native-babel-preset'],
  }
};
复制代码

babel 配置文件 .babelrc.js 与babel.config.js 之间的区别

理解 babel.config.js 和 babelrc

jest.config.js 文件配置

项目根目录新建 jest.config.js,配置可参考如下(笔者项目使用的是 ts 的语法,js 等配置可自行添加)

const { defaults } = require('ts-jest/presets');

module.exports = {
  ...defaults,
  preset: 'react-native',
  globals: {
    'ts-jest': {
      babelConfig: true,
    },
  },
  // 定义测试的文件目录,与配置文件同级目录下的 tests 文件夹内的 .tsx/.jsx 后缀名文件
  testRegex: '(/tests/.*\\.(test|spec))\\.[tj]sx?$',
  // 定义文件的编译方式
  transform: {
    '^.+\\.tsx?$': 'ts-jest',
  },
  // 定义了忽略进行 jest 执行的依赖包
  transformIgnorePatterns: [
    'node_modules/(?!(react-native|@testing-library|react-navigation|@react-navigation/.*|@react-native-community)/)',
  ],
  testPathIgnorePatterns: ['<rootDir>/node_modules/', '\\.snap$'],
  // 缓存文件生成的目录地址,注意在.gitignore 中忽略
  cacheDirectory: '.jest/cache',
  testEnvironment: 'jsdom',
  moduleNameMapper: {
    '^[@./a-zA-Z0-9$_-]+\\.(png|gif)$': '<rootDir>/node_modules/react-native/Libraries/Image/RelativeImageStub',
  },
};
复制代码

更多config 说明查看文档最底部 jest.config.js

基础知识

简单例子

常见的一个测试文件如下:

// sum.test.ts
const sum = (a, b) => {
	return a + b;
}

describe('Unit test explain', () => {
	test('sum success', () => {
    expect(sum(1, 2)).toBe(3);
  })
})
复制代码
  • describe 通常将测试套件分解为组件。意思就是我们可以将某个组件的整体功能进行分解,按每个模块或者每个功能进行区分。而 test/it 是执行个别测试的地方,用来描述每一个测试功能点。
  • test 称为测试用例,接收两个参数。第一个参数是该用例的描述,第二个是测试函数,用来定义逻辑。与 it 同义。
  • expect 是期望的意思,整行称为断言。上面的意思就是期望 1 + 2 能否等于 3
  • toBe 是一个匹配器,匹配 expect 中预期的值和匹配器中的值是否相等。

常见的匹配器

  • toBe(value) 是否完全相等,等同于 ===
  • toBeNull(value) 是否为 null
  • toBeUndefined() 是否为 undefined
  • toBeNaN() 匹配 NaN
  • toBeTruthy() 匹配 true
  • toBeFalsy()匹配 false
  • .not 后续匹配取反
  • toMatch(regexpOrString) 检查字符串是否匹配,可传字符串或者正则表达式
  • toMatchObject(object) 判断一个对象/数组是否属于子集
  • toContain(item) 匹配数组/Set/字符串中是否包含 item
  • **toContainEqual(item) 匹配数组中是否包含一个特定对象
  • toHaveProperty(keyPath, value) 匹配对象中深度嵌套的属性,判断在指定 keyPath 下是否有 value 属性
  • toHaveLength(number) 匹配对象 length
  • toThrow(err)/toThrowError(err) 匹配异常
  • expect 中传入函数才可以匹配到异常
  • toBeCalled()/toHaveBeCalled() 匹配函数是否被执行
  • toReturn()/toHaveReturned() 匹配函数是否有返回值
  • toReturnWith(value)/toHaveReturnedWith(value) 匹配函数返回值是否匹配

以上列举了常见的匹配器,更多信息可查看 预期 - 匹配器

Testing Library

我们再来看一个例子:

import React from 'react';
import "setimmediate"; // setimmediate 不要忘记引入
import { render } from '@testing-library/react-native';
import Button from '../lib/button';

describe('Button unit test', () => {
	test('render success', () => {
  	const { getByTestId } = render(<Button testID='button' />);
    
    // 期待找到 testID 为 button 的元素
    expect(() => getByTestId('button')).not.toThrow(/Unable to find an element with testID/);
  })
})
复制代码

上述例子是为了测试 Button 组件是否渲染成功,并通过 testID 标识是否能找到相应的 Dom 元素。

  • render() 用来对组件的渲染,透出的 getByTestId 方法用以接下来对 testID 的查找
  • getByTestId() 查找渲染的元素中,有无 testIDbtn 的元素
  • toThrow(err)匹配器须传入函数才可以匹配到异常,所以我们传入() => getByTestId('btn') 并且预期不抛出无法根据 testID 找到节点的错误

常见查询器

先看概述:

  • getBy... 返回查询的匹配节点,如果未查找到、或者查找到多个,则抛出一个描述性的错误。
  • quertBy... 返回查询的匹配节点,如果没有匹配的元素则返回null。如果匹配到多个,则抛出错误。
  • findBy... 返回一个Promise,该Promise在找到与匹配项时进行解析。如果未匹配到元素,或者超过匹配时间(默认1000ms)时,Promise将被拒绝。

同上,getAllBy... quertAllBy... findAllBy... 查询匹配条件的全部节点。 ​

常用的查询器:

  • getByRole 查询具有给定角色的元素。
  • getByLabelText 查询label与给定文本匹配的元素。
  • getByPlaceholderText 查询所有匹配占位符文本的元素。
  • getByText 查询所有文本节点与给定文本匹配的元素。
  • getByTestId 查询元素包含data-testid="${yourId}"的元素,在RN组件中,默认提供了testID属性。

更多查询器信息可查看 查询器

测试实例

快照测试

快照测试是第一次运行测试的时候,在不同情况下的渲染结果保存的一份快照文件,后面每次再运行快照测试时,都会和第一次的比较。

it("test snapshot", () => { 
  const component = render(<App />); 
  
  expect(component.toJSON()).toMatchSnapshot();
});
复制代码

使用 render 渲染 App 组件,然后匹配 App 组件的片段与快照匹配。

这时候,如果修改 App.js,测试将会失败,因为快照将不再符合条件,可使用 npm test -- -u 更新快照。

测试元素

test('render success', () => {
  const { getByText } = render(<Text>描述信息</Text>)

  expect(getByText('描述信息')).not.toThrow(/Unable to find an element with text/);
})
复制代码

说明

渲染一个 Text 文本,通过 getByText('描述信息') 来匹配渲染组件中有无符合 描述信息 的文本元素,如果不能匹配到,则抛出一个错误。

测试事件

// 模拟 onClose 事件
const onClose = jest.fn();

const { getByTestId } = render(<Button onPress={onClose} />);

act(() => {
  // 执行 testID 为 button 元素的点击事件
	fireEvent.press(getByTestId('button'));
})

// 点击 button 元素后 onClose 方法被调用达到预期效果,测试通过
expect(onClose).toBeCalled();
复制代码

说明

上例中,我们需要关心的是 Button 组件的点击事件是否被正确调用,这时候需要使用 Mock 函数。

  1. 使用 jest.fn() 定义一个模拟函数,接着将 onClose 传入示例 Button 组件的点击属性中,接下来是执行这个点击事件。 简单来说,这个 fireEvent 接收一个 Dom 节点,并模拟触发 Dom 事件,如:点击、内容更改、滑动等。​
  2. 通过 onClose 函数是否被执行(匹配器 toBeCalled)来判断是否完成对 onClose 方法的测试。

测试异步操作

// 模拟一个数据请求方法
const fetchData = (fn) => {
	axios.get('https://api_url/')
  .then(response => {
  	fn(response.data)
  })
}

// 使用 done() 方法
// 测试用例会等到 done 方法执行才结束
test('unit test', (done) => {
	fetchData((data) => {
  	expect(data).toMatchObject({
    	code: 200
    })
    done();
  })
})

复制代码

我们不能用常规的逻辑去测试异步操作,测试用例不会等到请求结束才执行。所以常规逻辑都会优先与异步操作前执行完。 ​

数据请求返回的是 Promise 对象

// 数据请求方法
const fetchData = () => {
	return axios.get('https://api_url/')
}

// 使用 done() 方法
test('unit test', (done) => {
	fetchData().then(res => {
  	expect(res.data).toMatchObject({
    	code: 200
    })
    done();
  })
})

// 或者直接使用 return 方法
test('unit test', () => {
	return fetchData().then(res => {
  	expect(res.data).toMatchObject({
    	code: 200
    })
  })
})
复制代码

测试Hook

注意:测试原则上,应避免对组件的 propsstate 进行断言。

我们使用 @testing-library/react-hooks 来测试 hook,因为测试原则上是对 DomUI 以及交互事件对 Dom\UI 所带来的影响进行测试,所以我们实际无法拿到定义在组件内部的 props 或者 state

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

// 构建更改数据的自定义 hook
const useVisibleAction = () => {
	const [visible, setVisible] = useState(false);
  const update = useCallback((value: boolean) => setVisible(value), []);
  
  return {
  	update,
    value: visible,
  }
}

it('unit test', () => {
	const { result } = renderHook(useVisibleAction);
  const { getByTestId } = render(<Button testID='testID' onPress={value => result.current.update(value)} />);
  
  act(() => {
    fireEvent.press(getByTestId('testID'));
  })

  expect(result.current.value).toBeTruthy();
})
复制代码

通过构建一个自定义 hook 函数,如代码中的 useVisibleAction,我们定义 update 来执行 setVisible 的方法,并最终将 { update, value: visible } 返回出去。

Button 组件中的 onPress 方法中,定义 update 执行,接着通过 fireEvent.press 调用 onPress 方法执行。

最终我们可以模拟 hook 的执行,并通过 result.current.value 获取到执行后的 visible 的值,进行断言,完成测试。

使用时注意点

测试文件

全局安装 jest 依赖

npm install -g jest-cli
复制代码

测试单个文件

测试单独的文件,只需要执行 jest demo.test.tsx 命令即可

注意:执行命令文件匹配,如果执行 jest demo.test.tsx 会匹配所有包含 demo.test.tsx 结尾的测试文件。

如:执行 jest demo.test.tsx 命令,test-demo.test.tsxtestdemo.test.tsx 等测试文件均会执行 。

测试全部文件

通过 npm run test 命令来执行脚本对所有测试文件进行测试并监听

mock 模拟库

mock 模拟 react-native 中标签

// TouchableHighlight
jest.mock('react-native/Libraries/Components/Touchable/TouchableHighlight', () => 'TouchableHighlight');

// Text
jest.mock('react-native/Libraries/Text/Text', () => 'Text');
复制代码

mock 模拟 @react-native-clipboard/clipboard 库中方法:setString()、getString()

jest.mock('@react-native-clipboard/clipboard', () => {
  let string = undefined;

  return {
    setString: (text) => string = text,
    getString: () => string,
  };
});
复制代码

jest.config.js

jest.config.js 更多配置选项说明查看 Configuring Jest

module.exports = {
  // Jest 配置基础的预设
  preset: 'react-native',
  
  // 模块使用的文件扩展名数组
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
  
  // 检测测试文件的模式(tests 目录下的 tsx 或 jsx 文件)
  testRegex: '(/tests/.*\\.(test|spec))\\.[tj]sx?$',
  
  // 从正则表达式到转换器的路径的映射, 转换器是提供同步功能以转换源文件的模块
  transform: {
    '^.+\\.tsx?$': 'ts-jest', // 转换 ts 模块文件
    '\\.[jt]sx?$': 'babel-jest', // 转换 js 模块文件
  },
  
  // 允许传输 Jest 将无法理解的第三方模块未翻译转换的模块
  transformIgnorePatterns: [
    'node_modules/(?!(@package-name)/)',
  ],
  
  // 匹配的路径下文件将跳过覆盖率信息
  testPathIgnorePatterns: ['<rootDir>/node_modules/', '\\.snap$'],  
  
  // 存储其缓存的依赖项信息的目录
  cacheDirectory: '.jest/cache',
  
  // 设置 jsdom 使用类似浏览器的环境,默认环境是 Node.js 环境
  testEnvironment: 'jsdom',
  
  // 从正则表达式到模块名或模块名数组的映射
   moduleNameMapper: {
    '^[@./a-zA-Z0-9$_-]+\\.(png|gif)$': '<rootDir>/node_modules/react-native/Libraries/Image/RelativeImageStub',
  },
  
  
  // 测试中的所有模块是否自动模拟
  automock: false,
  
  // Jest运行所有测试,在 n 次失败后让 Jest 停止运行测试
  bail: 1, // 和bail: true 等价
  
  // 在每次测试之前是否清除模拟调用和实例
  clearMocks: false,
  
  // 在执行测试时是否应收集覆盖率信息,可能会显著降低测试速度
  collectCoverage: false,
  
  // 收集覆盖率信息的匹配文件,数组后面项会叠加覆盖前面匹配项,collectCoverage 为 true 时有效
  collectCoverageFrom: [
    "**/*.{js,jsx}",
    "!**/node_modules/**",
    "!**/vendor/**"
  ],
  
  // 覆盖率文件的输出目录
  coverageDirectory: 'coverage',
  
  // 使用哪个提供程序为覆盖范围插入代码,默认:'label','v8' 目前处于实验性阶段
  coverageProvider: 'label',
  
  // 控制台输出的覆盖率报告信息摘要
  moduleFileExtensions: ['json', 'lcov', 'text', 'clover'],
  
  // 是否应在运行期间报告每个单独的测试,单个测试默认为 true
  verbose: false,
}
...
复制代码

参考
Jest 官方文档
testing-library (可能需翻墙才能访问)
如何使用 React Testing Library 和 Jest 测试 React 应用

分类:
前端
收藏成功!
已添加到「」, 点击更改