一文搞定前端自动化测试(React 实战)
前言
对不起,鸽了很久的实战终于出来了!
这篇文章是前端自动化测试系列的
React
实战部分,自动化测试系列会从理论走向实践,真正带领大家学会使用前端自动化测试框架,并能在业务中落地。看完整个系列,还不会使用自动化测试工具为生产提效,请来找我!
老规矩,点赞过两百,持续更新
Vue
与自动化测试的结合教程。
希望大家都能先打好基础再开始实战的部分!
实战部分的代码我放在了我的 Git 仓库:wjq990112 / Learing-React-Test[2],欢迎大家点个小星星 ⭐️,持续关注后续更新~
准备工作
方法一
强烈建议大家跟着方法一一步一步搭建好我们的实战环境!
大家可以使用 create-react-app
自己创建一个项目,两种方式创建:
npx create-react-app jest-react --template-typescript
或者:
npm install create-react-app -g
create-react-app jest-react --template-typescript
创建好项目后,就可以开始尝试一下自动化测试和 React
的结合的神奇效果了!
方法二
方法一如果大家觉得麻烦,也可以直接从 GitHub 拉我的代码下来,选择对应教程的分支:
# 基础教程
git checkout base
# 进阶教程: testing-library
git checkout advance/testing-library
# 进阶教程: enzyme
git checkout advance/enzyme
然后执行:
npm install
npm run start
服务跑在 http://localhost:3000[5],会自动打开浏览器,服务启动完成之后就能看到实战项目的界面啦!
当然,我们这个项目主要是为了讲解自动化测试的,界面就没做那么漂亮了。旨在让大家通过真正的代码实战来学习前端自动化测试,如果想把界面做得更漂亮可以把代码拉下来之后加上一些样式欧!
不过我还是建议大家能够使用方法一一步一步来,这样印象会更加深刻一点,能帮助你更快地学习和理解本文的内容!
基础教程
组件开发
我们完成准备工作后,就可以开始写代码了。
按照惯例,第一次要慢慢来,先适应一下,避免用力过猛:
我们先写个 HelloWorld,希望能给我们后面的学习带来好运!
// App.tsx
import React, { useState } from 'react';
import './App.css';
function App() {
const [content, setContent] = useState('Hello World!');
return (
<div
className="app"
// 方便测试用例中获取 DOM 节点
data-testid="container"
onClick={() => {
setContent('Hello Jack!');
}}
>
{content}
</div>
);
}
export default App;
非常简单的组件,点击一下会变成 Hello Jack!:
测试用例编写
首先,我们要思考一下,我们这个 HelloWorld 的组件,有哪些地方需要测试?
我们不妨站在用户的角度思考一下:
看到 Hello World! 点击 Hello World! 看到 Hello Jack!
那么,我们需要让这个流程走通,就需要通过这几个步骤:
HelloWorld 组件渲染正常, div
标签的内容为 Hello World!children
为 Hello World! 的div
标签被点击div
标签的children
变成 Hello Jack!
画一个流程图:
发现没有,这里我们使用的思想方式是 BDD,不了解 BDD 的同学,可以回去翻我上一篇文章:传送门[6]。
那么,既然我们已经明确了进行测试所需要的行动点,那么我们就可以开始写测试代码了,这里我们使用的是 React
官方默认的 **React Testing Library**[7]。
在创建项目的时候,React 已经默认为我们配置好了,基础教程中,我们不需要进行手动测试。
我们创建项目的时候会发现,在 src
目录下有一个 App.test.tsx
的文件,打开它我们会发现一个非常简单的测试用例:
// App.test.tsx
import React from 'react';
import { render } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
// render 方法返回一个包裹对象 对象中包括一些对 DOM 的查询/获取方法
// getByText: 通过标签的 text 获取 DOM
const { getByText } = render(<App />);
// 获取 text 匹配正则 /learn react/i 的 DOM
const linkElement = getByText(/learn react/i);
// 判断 DOM 是否在 Document 中
expect(linkElement).toBeInTheDocument();
});
这是一个最简单的 Demo,可能不懂的地方,我都在代码注释了,更多详细的内容还是要到 **React Testing Library**[8] 文档中去获取。
运行测试用例
我们先在控制台执行一下
npm run test
你会发现结果是这样的:
这是因为 getByTextId
这个 API,在没有找到对应的 DOM 节点的时候,会直接抛出异常。
现在我们把这部分的内容删掉,换成我们需要编写的测试用例:
// App.test.tsx
import React from 'react';
import { render, fireEvent, RenderResult } from '@testing-library/react';
import App from './App';
let wrapper: RenderResult;
// 运行每一个测试用例前先渲染组件
beforeEach(() => {
wrapper = render(<App />);
});
describe('Should render App component correctly', () => {
// 初始化文本内容为 "Hello World!"
test('Should render "Hello World!" correctly', () => {
// getByTestId: 通过属性 data-testid 来获取对应的 DOM
// 这里我们获取到的是上面 HelloWorld 组件中的 div 标签
const app = wrapper.getByTestId('container');
expect(app).toBeInTheDocument();
// 判断获取到的标签是否是 div
expect(app.tagName).toEqual('DIV');
// 判断 div 标签的 text 是否匹配正则 /world/i
expect(app.textContent).toMatch(/world/i);
});
// 点击后文本内容为 "Hello Jack!"
test('Should render "Hello Jack!" correctly after click', () => {
const app = wrapper.getByTestId('container');
// fireEvent: 模拟点击事件
fireEvent.click(app);
expect(app.textContent).toMatch(/jack/i);
});
});
这里可能有些同学开始不知所云了,没关系,一行一行来看我们的测试代码。
let wrapper: RenderResult;
// 运行每一个测试用例前先渲染组件
beforeEach(() => {
wrapper = render(<App />);
});
beforeEach
生命周期钩子会在每个测试用例运行之前运行,在这里,我们将 HelloWorld 组件渲染到了在 node
上模拟的 jsdom 环境中,其实就是在 node 上模拟了一个浏览器。
我们用 wrapper
变量来保存我们渲染出来的结果,然后再通过 React Testing Library 为我们封装的一些方法来获取对应的 DOM 元素。
describe('Should render App component correctly', () => {});
看到这里可能有些同学又懵了,其实这个很容易理解,就是字面意思 —— 为测试一个大的测试的单元添加一个描述。
当然,你也可以不写,直接写两个测试用例。如果不写的话,Jest
会默认用文件名作为测试单元描述。
我们的测试用例都写在
test('测试用例的描述', () => {});
的回调函数中,每一个 test
函数就是一个测试用例。
test
函数还有一个别名 it
,大家在后面如果看到
it('测试用例的描述', () => {});
也不要惊讶,知道它就是 test
就可以了。
上面的这部分教程,算是对上一篇文章中没有讲到的部分的补充,避免看文章的朋友们不知道这样写的原因。
现在我们运行
npm run test
结果就变成了这样:
看到一串片绿油油的结果,是不是很爽?
再来一遍
现在我们升级一下,也带大家看看 React Testing Library 官方的基础示例:
// hidden-message.js
import React from 'react';
// NOTE: React Testing Library works with React Hooks _and_ classes just as well
// and your tests will be the same however you write your components.
function HiddenMessage({ children }) {
const [showMessage, setShowMessage] = React.useState(false);
return (
<div>
<label htmlFor="toggle">Show Message</label>
<input
id="toggle"
type="checkbox"
onChange={(e) => setShowMessage(e.target.checked)}
checked={showMessage}
/>
{showMessage ? children : null}
</div>
);
}
export default HiddenMessage;
// __tests__/hidden-message.js
// these imports are something you'd normally configure Jest to import for you
// automatically. Learn more in the setup docs: https://testing-library.com/docs/react-testing-library/setup#cleanup
import '@testing-library/jest-dom';
// NOTE: jest-dom adds handy assertions to Jest and is recommended, but not required
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import HiddenMessage from '../hidden-message';
test('shows the children when the checkbox is checked', () => {
const testMessage = 'Test Message';
render(<HiddenMessage>{testMessage}</HiddenMessage>);
// query* functions will return the element or null if it cannot be found
// get* functions will return the element or throw an error if it cannot be found
expect(screen.queryByText(testMessage)).toBeNull();
// the queries can accept a regex to make your selectors more resilient to content tweaks and changes.
fireEvent.click(screen.getByLabelText(/show/i));
// .toBeInTheDocument() is an assertion that comes from jest-dom
// otherwise you could use .toBeDefined()
expect(screen.getByText(testMessage)).toBeInTheDocument();
});
上面一堆英文注释,肯定有英语不好的朋友会说:“啊你这照搬照套全是英文的我怎么看得懂啊~”
别急,我给你翻译一下!
// query* functions will return the element or null if it cannot be found
// get* functions will return the element or throw an error if it cannot be found
expect(screen.queryByText(testMessage)).toBeNull();
这一段代码,使用了一个 API:queryByText
,这个 API 的作用是,通过 text
来查找对应的 DOM。可是,queryByText
和 getByText
又有什么区别呢?
区别就在于 query*
类型的 API 在被调用的时候,如果没有找到对应的 DOM,会返回 null
,但是 get*
在没有找到对应的 DOM 时会直接报错。
// the queries can accept a regex to make your selectors more resilient to content tweaks and changes.
fireEvent.click(screen.getByLabelText(/show/i));
这一段代码,经过上面我的讲解应该不陌生了,模拟一次点击事件。模拟点击事件之前我们得先找到对应的 DOM,getByLabelText
API 就是通过 label
的内容找到对应的 DOM,传递的参数支持正则表达式。
// .toBeInTheDocument() is an assertion that comes from jest-dom
// otherwise you could use .toBeDefined()
expect(screen.getByText(testMessage)).toBeInTheDocument();
这一段代码用于判断 DOM 是否在 Document
中,其中 toBeInTheDocument
API 是 jest-dom
提供的方法,这个方法不是必须的,你也可以使用 jest
自带的 API toBeDefined
来判断。
实战的基础部分就这么多,想看进阶部分的同学可以往下继续学习观看~
进阶教程
弹出工程配置
准备工作中方法一的两种方式都可以创建一个基于 TypeScript 的 React
项目。使用 create-react-app
脚手架创建的项目中已经默认引入了自动化测试的工具,但是脚手架默认将工具的一些配置隐藏起来了,我们如果希望将配置弹出并进行手动配置,就需要运行:
npm run eject
将默认的一些工程化配置弹出,弹出后项目的目录会变成这样:
README.md node_modules package.json scripts
config package-lock.json public src
会比项目刚创建的时候多了 config
和 scripts
两个文件夹,里面是一些脚手架默认的工程配置文件,如果对工程化不是非常了解,千万别乱改!
除了多了两个文件目录之外,在 package.json
中也添加了很多依赖,同时还添加了 babel
和 jest
的配置:
"jest": {
"roots": [
"<rootDir>/src"
],
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/**/*.d.ts"
],
"setupFiles": [
"react-app-polyfill/jsdom"
],
"setupFilesAfterEnv": [
"<rootDir>/src/setupTests.js"
],
"testMatch": [
"<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}",
"<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"
],
"testEnvironment": "jest-environment-jsdom-fourteen",
"transform": {
"^.+\\.(js|jsx|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
"^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
"^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
},
"transformIgnorePatterns": [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$",
"^.+\\.module\\.(css|sass|scss)$"
],
"modulePaths": [],
"moduleNameMapper": {
"^react-native$": "react-native-web",
"^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy"
},
"moduleFileExtensions": [
"web.js",
"js",
"web.ts",
"ts",
"web.tsx",
"tsx",
"json",
"web.jsx",
"jsx",
"node"
],
"watchPlugins": [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname"
]
},
"babel": {
"presets": [
"react-app"
]
}
这些都是 React
官方提供的 babel
和 jest
的默认配置,如果还没搞懂 babel
和 jest
,不要随意修改欧!
迁移 Jest/Babel 配置
配置都放在 package.json
中不是很方便操作,如果要修改每次都要到 package.json
中去找,我们把他们单独抽出来:
首先在根目录下创建两个文件 babel.config.js
jest.config.js
,然后分别将 package.json
中的 Babel 配置和 Jest 配置复制到对应的 config 文件中:
// jest.config.js
module.exports = {
roots: ['<rootDir>/src'],
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts'],
setupFiles: ['react-app-polyfill/jsdom'],
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
'<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}'
],
testEnvironment: 'jest-environment-jsdom-fourteen',
transform: {
'^.+\\.(js|jsx|ts|tsx)$': '<rootDir>/node_modules/babel-jest',
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)':
'<rootDir>/config/jest/fileTransform.js'
},
transformIgnorePatterns: [
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
'^.+\\.module\\.(css|sass|scss)$'
],
modulePaths: [],
moduleNameMapper: {
'^react-native$': 'react-native-web',
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy'
},
moduleFileExtensions: [
'web.js',
'js',
'web.ts',
'ts',
'web.tsx',
'tsx',
'json',
'web.jsx',
'jsx',
'node'
],
watchPlugins: [
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname'
]
};
// babel.config.js
module.exports = {
presets: ['react-app']
};
Jest 配置详解
我猜,这里肯定又有同学说:“啊你这个乱七八糟一大堆配置我怎么看得懂啊~”
别急,让我们逐条来看!
babel.config.js
的内容太简单,就不详细讲解了,我们主要来讲一讲 jest.config.js
:
roots: ['<rootDir>/src'],
roots
是用于指定 Jest 的根目录的,Jest 只会检测在根目录下的测试用例并运行。
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts'],
src
目录下文件很多,但是我们要生成测试覆盖率报告,一些无关的文件就不能被统计到覆盖率当中。
collectCoverageFrom
是用于指定测试覆盖率统计范围的:src
下的所有 js,jsx,ts,tsx
文件,同时排除 .d.ts
类型声明文件。
setupFiles: ['react-app-polyfill/jsdom'],
setupFiles
是用于指定创建测试环境前的准备文件的,这里引入 react-app-polyfill/jsdom
解决 jsdom
的兼容性问题。
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
setupFilesAfterEnv
是用于指定测试环境创建完成后为每个测试文件编写的配置文件。
我们可以看到默认的 setupTests.ts
中内容是这样的:
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';
在测试环境创建完成后为每一个测试文件都引入 @testing-library/jest-dom/extend-expect
,为 Jest 提供了更多适配 React
的匹配器,如 toHaveTextContent
。
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
'<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}'
],
testMatch
是用于配置 Jest 匹配测试文件的规则的,这里我们看到配置项中填写的是在 __tests__
文件夹下的所有 js,jsx,ts,tsx
以及以 .spec/.test
结尾的 js,jsx,ts,tsx
文件。
testEnvironment: 'jest-environment-jsdom-fourteen',
testEnvironment
应该能见名知义了,就是用于指定测试用例运动的环境的。
我们知道 Jest 是运行在 node 环境的,但是我们的前端代码却是运行在浏览器环境中,因此我们必须使用一些方法在 node 环境下模拟浏览器环境。
这里 React
官方推荐的是使用 jest-environment-jsdom-fourteen
,感兴趣的同学可以去搜索一下这个库,现在已经有了 sixteen
版本了。
transform: {
'^.+\\.(js|jsx|ts|tsx)$': '<rootDir>/node_modules/babel-jest',
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)':
'<rootDir>/config/jest/fileTransform.js'
},
transform
是用于配置文件处理模块的。
我们在测试的过程中,其实是需要去掉 CSS 和其他与组件逻辑相关性不大的静态资源的,但是在我们的组件代码中有时候又需要引入这些代码。
那么在我们测试的时候,就需要指定一些模块来处理/代替这些文件,不然就可能以为找不到模块的问题报错。
这部分的代码就是指定了所有的 js,jsx,ts,tsx
使用 babel-jest
的插件做处理,所有的 css
文件使用 <rootDir>/config/jest/cssTransform.js
模块做处理,所有的非 js,jsx,ts,tsx,css,json
文件,都使用 <rootDir>/config/jest/fileTransform.js
模块做处理。
那既然用到了这两个模块,我们就到模块看看 React
官方的配置是什么样的:
// cssTransform.js
'use strict';
module.exports = {
process() {
return 'module.exports = {};';
},
getCacheKey() {
// The output is always the same.
return 'cssTransform';
}
};
cssTransform.js
模块中我们可以看到默认使用了一个空的模块代替 css
文件。
// fileTransform.js
'use strict';
const path = require('path');
const camelcase = require('camelcase');
// This is a custom Jest transformer turning file imports into filenames.
// http://facebook.github.io/jest/docs/en/webpack.html
module.exports = {
process(src, filename) {
const assetFilename = JSON.stringify(path.basename(filename));
if (filename.match(/\.svg$/)) {
// Based on how SVGR generates a component name:
// https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6
const pascalCaseFilename = camelcase(path.parse(filename).name, {
pascalCase: true
});
const componentName = `Svg${pascalCaseFilename}`;
return `const React = require('react');
module.exports = {
__esModule: true,
default: ${assetFilename},
ReactComponent: React.forwardRef(function ${componentName}(props, ref) {
return {
$$typeof: Symbol.for('react.element'),
type: 'svg',
ref: ref,
key: null,
props: Object.assign({}, props, {
children: ${assetFilename}
})
};
}),
};`;
}
return `module.exports = ${assetFilename};`;
}
};
fileTransform.js
模块中可以看到默认的配置是当文件名是以 .svg
结尾时,则创建一个 React
的 SVG 组件并返回,否则直接返回文件名。
transformIgnorePatterns: [
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
'^.+\\.module\\.(css|sass|scss)$'
],
transformIgnorePatterns
用于配置文件处理模块应该忽略的文件,React 官方的配置是忽略 node_modules
文件夹下的所有 js,jsx,ts,tsx
,忽略所有的 CSS Module 文件。
modulePaths: [],
modulePaths
用于指定模块的查找路径,默认会在 node_modules
下查找,如果需要在其他的文件路径下查找模块,可以手动指定文件路径。
moduleNameMapper: {
'^react-native$': 'react-native-web',
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy'
},
moduleNameMapper
用于对模块映射处理。'^react-native$': 'react-native-web'
是对 React Native 做配置的,Web 应用可以删除,'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy'
是对 CSS Module 做映射,将 CSS Module 转换成键值对的形式。
moduleFileExtensions: [
'web.js',
'js',
'web.ts',
'ts',
'web.tsx',
'tsx',
'json',
'web.jsx',
'jsx',
'node'
],
moduleFileExtensions
用于配置需要查找的文件后缀名,如果是 React
的单页 Web 应用,可以删掉其中非 js,jsx,ts,tsx
的后缀名。
watchPlugins: [
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname'
],
watchPlugins
用于指定 Jest 在 watch
模式下的插件,这部分的配置我们就用 React
官方推荐的就行,基本不需要我们改动。
更多配置
其实 Jest 有很多非常实用的配置项,如果你在测试的过程中遇到问题,不妨尝试一下阅读官方的文档:Jest 官方文档[9]。
开始动手
在写这个 Demo 之前想了一下,要是做得太丑了估计没人爱看,于是决定做好看点,可是我审美又不是很到位,想了想最终决定把 Dell 老师的 TODO List
中的样式搬过来,逻辑和测试代码自己写,站在巨人的肩膀上就好多了。
先来看下效果:
组件代码我就不详细讲解了,如果想看组件代码的话可以到 GitHub 仓库 clone
下来,然后切换到 advance/testing-library
分支。
我们重点就讲解一下如何写测试的部分!
单元测试(Testing-Library)
首先,我们要分析各个组件需要测试什么功能,这一点非常重要。
如果一开始没有明确各个组件需要测试的功能,很有可能过度测试或者遗漏测试分支!
Header 组件
input
存在且value
为空input
能输入input
能回车提交input
能在提交后将value
置空List 组件
列表为空,无列表项,右上角计数器存在且值为 0 列表不为空,存在列表项,右上角计数器存在且为列表长度,列表项删除按钮存在,列表项可删除 列表不为空,存在列表项,右上角计数器存在且为列表长度,列表项内容点击后变成 input
,回车后可修改对应列表项内容
明确了我们需要测试的功能,就可以开始写单元测试的代码了,测试一下各个组件是否能够正常工作:
// Header.test.tsx
import React from 'react';
import { render, fireEvent, RenderResult } from '@testing-library/react';
import Header from '../../components/Header';
let wrapper: RenderResult;
let input: HTMLInputElement;
const addUndoItem = jest.fn();
beforeEach(() => {
wrapper = render(<Header addUndoItem={addUndoItem} />);
input = wrapper.getByTestId('header-input') as HTMLInputElement;
});
afterEach(() => {
wrapper = null;
input = null;
});
describe('Header 组件', () => {
it('组件初始化正常', () => {
// input 存在
expect(input).not.toBeNull();
// 组件初始化 input value 为空
expect(input.value).toEqual('');
});
it('输入框应该能输入', () => {
const inputEvent = {
target: {
value: 'Learn Jest'
}
};
// 模拟输入
// 输入后 input value 为输入值
fireEvent.change(input, inputEvent);
expect(input.value).toEqual(inputEvent.target.value);
});
it('输入框回车后应该能提交并清空', () => {
const inputEvent = {
target: {
value: 'Learn Jest'
}
};
const keyboardEvent = {
keyCode: 13
};
// 模拟回车
// 调用 addUndoItem props 调用时参数为 input value
// input value 置空
fireEvent.change(input, inputEvent);
fireEvent.keyUp(input, keyboardEvent);
expect(addUndoItem).toHaveBeenCalled();
expect(addUndoItem).toHaveBeenCalledWith(inputEvent.target.value);
expect(input.value).toEqual('');
});
});
// List.test.tsx
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import List, { IList } from '../../components/List';
describe('List 组件', () => {
it('组件初始化正常', () => {
const props: IList = {
list: [],
deleteItem: jest.fn(),
changeStatus: jest.fn(),
handleBlur: jest.fn(),
valueChange: jest.fn()
};
const wrapper = render(<List {...props} />);
const count = wrapper.queryByTestId('count');
// 计数器存在且数值为 0
expect(count).not.toBeNull();
expect(count.textContent).toEqual('0');
const list = wrapper.queryAllByTestId('list-item');
// 列表项为空
expect(list).toHaveLength(0);
});
it('列表项应该能删除', () => {
const props: IList = {
list: [{ status: 'div', value: 'Learn Jest' }],
deleteItem: jest.fn(),
changeStatus: jest.fn(),
handleBlur: jest.fn(),
valueChange: jest.fn()
};
const wrapper = render(<List {...props} />);
const count = wrapper.queryByTestId('count');
// 计数器存在且数值为 1
expect(count).not.toBeNull();
expect(count.textContent).toEqual('1');
const list = wrapper.queryAllByTestId('list-item');
// 列表项不为空
expect(list).toHaveLength(1);
const deleteBtn = wrapper.queryAllByTestId('delete-item');
// 删除按钮不为空
expect(deleteBtn).toHaveLength(1);
const e: Partial<React.MouseEvent> = {};
fireEvent.click(deleteBtn[0], e);
// 阻止事件冒泡
expect(props.changeStatus).not.toHaveBeenCalled();
// deleteItem 被调用 参数为 0
expect(props.deleteItem).toHaveBeenCalled();
expect(props.deleteItem).toHaveBeenCalledWith(0);
});
it('列表项应该能编辑', () => {
const props: IList = {
list: [
{ status: 'div', value: 'Learn Jest' },
{ status: 'input', value: 'Learn Enzyme' }
],
deleteItem: jest.fn(),
changeStatus: jest.fn(),
handleBlur: jest.fn(),
valueChange: jest.fn()
};
const wrapper = render(<List {...props} />);
const list = wrapper.queryAllByTestId('list-item');
// 第一项未处于编辑状态 第二项处于编辑状态
expect(list[0].querySelector('[data-testid="input"]')).toBeNull();
expect(list[1].querySelector('[data-testid="input"]')).not.toBeNull();
// 点击第一项
fireEvent.click(list[0]);
// changeStatus 被调用 参数为 0
expect(props.changeStatus).toHaveBeenCalled();
expect(props.changeStatus).toHaveBeenCalledWith(0);
// 第二项 input 输入
fireEvent.change(list[1].querySelector('[data-testid="input"]'), {
target: { value: 'Learn Testing Library' }
});
// valueChange 被调用 参数为 1 Learn Enzyme
expect(props.valueChange).toHaveBeenCalled();
expect(props.valueChange).toHaveBeenCalledWith(1, 'Learn Testing Library');
// 第二项 input 框失焦
fireEvent.blur(list[1].querySelector('[data-testid="input"]'));
// handleBlur 被调用 参数为 1
expect(props.handleBlur).toHaveBeenCalled();
expect(props.handleBlur).toHaveBeenCalledWith(1);
});
});
单元测试部分的代码基本上都是上一篇文章中有介绍到的一些匹配器,没有看过上一篇或者看了还是一头雾水的同学可以回去复习一下:《试试前端自动化测试!(基础篇)》[10],这部分就不详细讲解了。
集成测试(Testing-Library)
可能有同学会说:“你上面这个内容测试了单独的组件能不能正常工作,万一他组合起来工作不正常呢?”
如果你看到这里,也有这种感觉的话,就说明你已经比较理解自动化测试了。
单元测试有一个默认的前提:如果一份代码的所有组成部分能够正常工作,那么这份代码就能正常工作。
就好像一个齿轮组,如果齿轮组中的所有齿轮都能正常工作,那么整个齿轮组就能正常工作。
正常来讲,在企业开发中,如果我们能够实现较高的单元测试覆盖率,那么我们就没有必要再去写集成测试的代码,来检测组合后的代码是否能正常工作。
但是呢,这里又有点不一样,为什么这么说呢?
有没有发现上面的测试代码中,并没有测试Header 组件输入后 List 组件是否有增加这功能?
所以这里就需要借助集成测试来测试一下两个组件组合后,整个应用的功能正不正常:
// App.resolved.test.tsx
import React from 'react';
import { render, fireEvent, act, RenderResult } from '@testing-library/react';
import App from '../../App';
import axios from 'axios';
jest.mock('axios');
axios.get.mockResolvedValue({
data: {
code: 200,
data: [
{
status: 'div',
value: '学习 Jest'
},
{
status: 'div',
value: '学习 Enzyme'
},
{
status: 'div',
value: '学习 Testing-Library'
}
],
message: 'success'
}
});
let wrapper: RenderResult;
let headerInput: HTMLInputElement;
let count: HTMLDivElement;
let list: HTMLLIElement[];
let input: HTMLInputElement[];
let deleteBtn: HTMLDivElement[];
// 运行每一个测试用例前先渲染组件
beforeEach(async () => {
await act(async () => {
wrapper = render(<App />);
});
headerInput = wrapper.getByTestId('header-input') as HTMLInputElement;
count = wrapper.queryByTestId('count') as HTMLDivElement;
list = wrapper.queryAllByTestId('list-item') as HTMLLIElement[];
input = wrapper.queryAllByTestId('input') as HTMLInputElement[];
deleteBtn = wrapper.queryAllByTestId('delete-item') as HTMLDivElement[];
});
// 运行后重置
afterEach(() => {
wrapper = null;
headerInput = null;
count = null;
list = [];
input = [];
deleteBtn = [];
});
describe('App 组件(请求成功时)', () => {
it('组件初始化正常', () => {
// headerInput 存在
expect(headerInput).not.toBeNull();
// 组件初始化 headerInput value 为空
expect(headerInput.value).toEqual('');
// 计数器存在且数值为 3
expect(count).not.toBeNull();
expect(count.textContent).toEqual('3');
// 列表项不为空且长度为 3
expect(list).toHaveLength(3);
// 没有列表项处于编辑状态
expect(input).toHaveLength(0);
});
it('输入框提交后列表项应该增加', () => {
fireEvent.change(headerInput, {
target: { value: '分享自动化测试学习成果' }
});
fireEvent.keyUp(headerInput, { keyCode: 13 });
expect(count.textContent).toEqual('4');
// 会触发 DOM 变化 需重新查询一次
list = wrapper.queryAllByTestId('list-item') as HTMLLIElement[];
expect(list).toHaveLength(4);
// 最后一项的内容为添加的内容
expect(list[3]).toHaveTextContent('分享自动化测试学习成果');
});
it('列表项删除后应该能减少', () => {
fireEvent.click(deleteBtn[2]);
expect(count.textContent).toEqual('2');
// 会触发 DOM 变化 需重新查询一次
list = wrapper.queryAllByTestId('list-item') as HTMLLIElement[];
expect(list).toHaveLength(2);
});
it('列表项应该能编辑并提交', () => {
fireEvent.click(list[2]);
const editingItemInput = list[2].querySelector(
'[data-testid="input"]'
) as HTMLInputElement;
// 第一 二项未处于编辑状态 第三项处于编辑状态
expect(list[0].querySelector('[data-testid="input"]')).toBeNull();
expect(list[1].querySelector('[data-testid="input"]')).toBeNull();
expect(editingItemInput).not.toBeNull();
// 第三项输入
fireEvent.change(editingItemInput, {
target: { value: 'Learn Testing Library' }
});
expect(editingItemInput.value).toEqual('Learn Testing Library');
// 失焦后内容被改变
fireEvent.blur(editingItemInput);
expect(list[2]).toHaveTextContent('Learn Testing Library');
});
});
认真看了代码的同学可能会对这些代码感到疑惑:
// mock api
jest.mock('axios');
axios.get.mockResolvedValue({
data: {
code: 200,
data: [
{
status: 'div',
value: '学习 Jest'
},
{
status: 'div',
value: '学习 Enzyme'
},
{
status: 'div',
value: '学习 Testing-Library'
}
],
message: 'success'
}
});
// async render
await act(async () => {
wrapper = render(<App />);
});
因为我们的应用中用到了 axios
来请求本地数据,最终呈现在页面上。
在实际的开发当中肯定是调用后端给的接口来获取数据或对数据进行操作,这就引出了很重要的一点:测试代码不能对业务代码产生侵入性!
事实上这个侵入性不只是对业务代码的侵入,还有对后端接口的侵入。
上面 mock api
的代码,其实就是在测试环境下模拟一个接口的返回值,因为 axios
的 get
方法返回的是一个 Promise
,因此我们也应该调用 mockResolvedValue
来模拟接口 resolve
状态的结果。
后面的 async render
其实也是为这个 axios
服务的,因为 axios
请求接口是一个异步的过程,如果我们使用同步的方式来渲染,异步事件会被放入事件队列中,等到同步的代码执行完成。
这样的话就会出现异步事件还没执行,测试用例就已经跑完了的情况,最终导致测试可能不通过。
当然,这里只是模拟了接口 resolve
的状态,我们还可以创建一个 App.rejected.test.tsx
文件,来测试接口 reject
的状态(实际开发中,接口 reject
可能也需要一个友好的提示)。
单元测试 & 集成测试(Enzyme)
除了 React
官方推荐的 Testing-Library
,Airbnb
公司也推出了一款测试框架 Enzyme
,同样也是非常好用的一款测试框架,设计思想略有不同,感兴趣的同学可以到这个项目的 GitHub 仓库查看,记得切换到 advance/enzyme
分支。
Enzyme
的代码就是完全由 Dell 老师编写的了,这里仅供大家参考学习~
单元测试(Hooks 相关)
在使用 React Hooks
开发的过程中我们可能会将一些重复逻辑抽离成公共的 Hooks
,这公共 Hooks
的可靠性也很重要,Testing-Library
还为我们提供了专门用于测试 React Hooks
的工具:`react-hooks-testing-library`[11]
参考资料
Dell Lee:前端要学的测试课 从 Jest 入门到 TDD/BDD 双实战[12] jest-dom 官方文档[13] react-testing-library 官方文档[14] Enzyme 官方文档[15]
参考资料
《不想痛失薪资普调和年终奖?试试自动化测试!(基础篇)》: https://juejin.im/post/5eeae4f7e51d4574195ed982
[2]wjq990112 / Learing-React-Test: https://github.com/wjq990112/Learing-React-Test
[3]@神三元: https://juejin.im/user/5c45ddf06fb9a04a006f5491
[4]《React Hooks 与 Immutable 数据流实战》: https://juejin.im/book/5da96626e51d4524ba0fd237
[5]http://localhost:3000: http://localhost:3000
[6]传送门: https://juejin.im/post/5eeae4f7e51d4574195ed982
[7]React Testing Library: https://github.com/testing-library/react-testing-library
[8]React Testing Library: https://github.com/testing-library/react-testing-library
[9]Jest 官方文档: https://jestjs.io/docs/zh-Hans/getting-started
[10]《试试前端自动化测试!(基础篇)》: https://juejin.im/post/5eeae4f7e51d4574195ed982
[11]react-hooks-testing-library
: https://github.com/testing-library/react-hooks-testing-library
Dell Lee:前端要学的测试课 从 Jest 入门到 TDD/BDD 双实战: https://coding.imooc.com/learn/list/372.html
[13]jest-dom 官方文档: https://github.com/testing-library/jest-dom
[14]react-testing-library 官方文档: https://github.com/testing-library/react-testing-library
[15]Enzyme 官方文档: https://enzymejs.github.io/enzyme/
如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:
点个「在看」,让更多的人也能看到这篇内容(喜欢不点在看,都是耍流氓 -_-) 欢迎加我微信「TH0000666」一起交流学习... 关注公众号「前端Sharing」,持续为你推送精选好文。