一文搞定前端自動化測試(React 實(shí)戰(zhàn))
前言
對不起,鴿了很久的實(shí)戰(zhàn)終于出來了!
這篇文章是前端自動化測試系列的
React實(shí)戰(zhàn)部分,自動化測試系列會從理論走向?qū)嵺`,真正帶領(lǐng)大家學(xué)會使用前端自動化測試框架,并能在業(yè)務(wù)中落地。看完整個(gè)系列,還不會使用自動化測試工具為生產(chǎn)提效,請來找我!
老規(guī)矩,點(diǎn)贊過兩百,持續(xù)更新
Vue與自動化測試的結(jié)合教程。
希望大家都能先打好基礎(chǔ)再開始實(shí)戰(zhàn)的部分!
實(shí)戰(zhàn)部分的代碼我放在了我的 Git 倉庫:wjq990112 / Learing-React-Test[2],歡迎大家點(diǎn)個(gè)小星星 ??,持續(xù)關(guān)注后續(xù)更新~
準(zhǔn)備工作
方法一
強(qiáng)烈建議大家跟著方法一一步一步搭建好我們的實(shí)戰(zhàn)環(huán)境!
大家可以使用 create-react-app 自己創(chuàng)建一個(gè)項(xiàng)目,兩種方式創(chuàng)建:
npx create-react-app jest-react --template-typescript
或者:
npm install create-react-app -g
create-react-app jest-react --template-typescript
創(chuàng)建好項(xiàng)目后,就可以開始嘗試一下自動化測試和 React 的結(jié)合的神奇效果了!
方法二
方法一如果大家覺得麻煩,也可以直接從 GitHub 拉我的代碼下來,選擇對應(yīng)教程的分支:
# 基礎(chǔ)教程
git checkout base
# 進(jìn)階教程: testing-library
git checkout advance/testing-library
# 進(jìn)階教程: enzyme
git checkout advance/enzyme
然后執(zhí)行:
npm install
npm run start
服務(wù)跑在 http://localhost:3000[5],會自動打開瀏覽器,服務(wù)啟動完成之后就能看到實(shí)戰(zhàn)項(xiàng)目的界面啦!
當(dāng)然,我們這個(gè)項(xiàng)目主要是為了講解自動化測試的,界面就沒做那么漂亮了。旨在讓大家通過真正的代碼實(shí)戰(zhàn)來學(xué)習(xí)前端自動化測試,如果想把界面做得更漂亮可以把代碼拉下來之后加上一些樣式歐!
不過我還是建議大家能夠使用方法一一步一步來,這樣印象會更加深刻一點(diǎn),能幫助你更快地學(xué)習(xí)和理解本文的內(nèi)容!
基礎(chǔ)教程
組件開發(fā)
我們完成準(zhǔn)備工作后,就可以開始寫代碼了。
按照慣例,第一次要慢慢來,先適應(yīng)一下,避免用力過猛:
我們先寫個(gè) HelloWorld,希望能給我們后面的學(xué)習(xí)帶來好運(yùn)!
// App.tsx
import React, { useState } from 'react';
import './App.css';
function App() {
const [content, setContent] = useState('Hello World!');
return (
<div
className="app"
// 方便測試用例中獲取 DOM 節(jié)點(diǎn)
data-testid="container"
onClick={() => {
setContent('Hello Jack!');
}}
>
{content}
</div>
);
}
export default App;
非常簡單的組件,點(diǎn)擊一下會變成 Hello Jack!:
測試用例編寫
首先,我們要思考一下,我們這個(gè) HelloWorld 的組件,有哪些地方需要測試?
我們不妨站在用戶的角度思考一下:
看到 Hello World! 點(diǎn)擊 Hello World! 看到 Hello Jack!
那么,我們需要讓這個(gè)流程走通,就需要通過這幾個(gè)步驟:
HelloWorld 組件渲染正常, div標(biāo)簽的內(nèi)容為 Hello World!children為 Hello World! 的div標(biāo)簽被點(diǎn)擊div標(biāo)簽的children變成 Hello Jack!
畫一個(gè)流程圖:

發(fā)現(xiàn)沒有,這里我們使用的思想方式是 BDD,不了解 BDD 的同學(xué),可以回去翻我上一篇文章:傳送門[6]。
那么,既然我們已經(jīng)明確了進(jìn)行測試所需要的行動點(diǎn),那么我們就可以開始寫測試代碼了,這里我們使用的是 React 官方默認(rèn)的 **React Testing Library**[7]。
在創(chuàng)建項(xiàng)目的時(shí)候,React 已經(jīng)默認(rèn)為我們配置好了,基礎(chǔ)教程中,我們不需要進(jìn)行手動測試。
我們創(chuàng)建項(xiàng)目的時(shí)候會發(fā)現(xiàn),在 src 目錄下有一個(gè) App.test.tsx 的文件,打開它我們會發(fā)現(xiàn)一個(gè)非常簡單的測試用例:
// App.test.tsx
import React from 'react';
import { render } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
// render 方法返回一個(gè)包裹對象 對象中包括一些對 DOM 的查詢/獲取方法
// getByText: 通過標(biāo)簽的 text 獲取 DOM
const { getByText } = render(<App />);
// 獲取 text 匹配正則 /learn react/i 的 DOM
const linkElement = getByText(/learn react/i);
// 判斷 DOM 是否在 Document 中
expect(linkElement).toBeInTheDocument();
});
這是一個(gè)最簡單的 Demo,可能不懂的地方,我都在代碼注釋了,更多詳細(xì)的內(nèi)容還是要到 **React Testing Library**[8] 文檔中去獲取。
運(yùn)行測試用例
我們先在控制臺執(zhí)行一下
npm run test
你會發(fā)現(xiàn)結(jié)果是這樣的:

這是因?yàn)?getByTextId 這個(gè) API,在沒有找到對應(yīng)的 DOM 節(jié)點(diǎn)的時(shí)候,會直接拋出異常。
現(xiàn)在我們把這部分的內(nèi)容刪掉,換成我們需要編寫的測試用例:
// App.test.tsx
import React from 'react';
import { render, fireEvent, RenderResult } from '@testing-library/react';
import App from './App';
let wrapper: RenderResult;
// 運(yùn)行每一個(gè)測試用例前先渲染組件
beforeEach(() => {
wrapper = render(<App />);
});
describe('Should render App component correctly', () => {
// 初始化文本內(nèi)容為 "Hello World!"
test('Should render "Hello World!" correctly', () => {
// getByTestId: 通過屬性 data-testid 來獲取對應(yīng)的 DOM
// 這里我們獲取到的是上面 HelloWorld 組件中的 div 標(biāo)簽
const app = wrapper.getByTestId('container');
expect(app).toBeInTheDocument();
// 判斷獲取到的標(biāo)簽是否是 div
expect(app.tagName).toEqual('DIV');
// 判斷 div 標(biāo)簽的 text 是否匹配正則 /world/i
expect(app.textContent).toMatch(/world/i);
});
// 點(diǎn)擊后文本內(nèi)容為 "Hello Jack!"
test('Should render "Hello Jack!" correctly after click', () => {
const app = wrapper.getByTestId('container');
// fireEvent: 模擬點(diǎn)擊事件
fireEvent.click(app);
expect(app.textContent).toMatch(/jack/i);
});
});
這里可能有些同學(xué)開始不知所云了,沒關(guān)系,一行一行來看我們的測試代碼。
let wrapper: RenderResult;
// 運(yùn)行每一個(gè)測試用例前先渲染組件
beforeEach(() => {
wrapper = render(<App />);
});
beforeEach 生命周期鉤子會在每個(gè)測試用例運(yùn)行之前運(yùn)行,在這里,我們將 HelloWorld 組件渲染到了在 node 上模擬的 jsdom 環(huán)境中,其實(shí)就是在 node 上模擬了一個(gè)瀏覽器。
我們用 wrapper 變量來保存我們渲染出來的結(jié)果,然后再通過 React Testing Library 為我們封裝的一些方法來獲取對應(yīng)的 DOM 元素。
describe('Should render App component correctly', () => {});
看到這里可能有些同學(xué)又懵了,其實(shí)這個(gè)很容易理解,就是字面意思 —— 為測試一個(gè)大的測試的單元添加一個(gè)描述。
當(dāng)然,你也可以不寫,直接寫兩個(gè)測試用例。如果不寫的話,Jest 會默認(rèn)用文件名作為測試單元描述。
我們的測試用例都寫在
test('測試用例的描述', () => {});
的回調(diào)函數(shù)中,每一個(gè) test 函數(shù)就是一個(gè)測試用例。
test 函數(shù)還有一個(gè)別名 it,大家在后面如果看到
it('測試用例的描述', () => {});
也不要驚訝,知道它就是 test 就可以了。
上面的這部分教程,算是對上一篇文章中沒有講到的部分的補(bǔ)充,避免看文章的朋友們不知道這樣寫的原因。
現(xiàn)在我們運(yùn)行
npm run test
結(jié)果就變成了這樣:

看到一串片綠油油的結(jié)果,是不是很爽?
再來一遍
現(xiàn)在我們升級一下,也帶大家看看 React Testing Library 官方的基礎(chǔ)示例:
// 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();
這一段代碼,使用了一個(gè) API:queryByText,這個(gè) API 的作用是,通過 text 來查找對應(yīng)的 DOM。可是,queryByText 和 getByText 又有什么區(qū)別呢?
區(qū)別就在于 query* 類型的 API 在被調(diào)用的時(shí)候,如果沒有找到對應(yīng)的 DOM,會返回 null,但是 get* 在沒有找到對應(yīng)的 DOM 時(shí)會直接報(bào)錯(cuò)。
// the queries can accept a regex to make your selectors more resilient to content tweaks and changes.
fireEvent.click(screen.getByLabelText(/show/i));
這一段代碼,經(jīng)過上面我的講解應(yīng)該不陌生了,模擬一次點(diǎn)擊事件。模擬點(diǎn)擊事件之前我們得先找到對應(yīng)的 DOM,getByLabelText API 就是通過 label 的內(nèi)容找到對應(yīng)的 DOM,傳遞的參數(shù)支持正則表達(dá)式。
// .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 提供的方法,這個(gè)方法不是必須的,你也可以使用 jest 自帶的 API toBeDefined 來判斷。
實(shí)戰(zhàn)的基礎(chǔ)部分就這么多,想看進(jìn)階部分的同學(xué)可以往下繼續(xù)學(xué)習(xí)觀看~
進(jìn)階教程
彈出工程配置
準(zhǔn)備工作中方法一的兩種方式都可以創(chuàng)建一個(gè)基于 TypeScript 的 React 項(xiàng)目。使用 create-react-app 腳手架創(chuàng)建的項(xiàng)目中已經(jīng)默認(rèn)引入了自動化測試的工具,但是腳手架默認(rèn)將工具的一些配置隱藏起來了,我們?nèi)绻M麑⑴渲脧棾霾⑦M(jìn)行手動配置,就需要運(yùn)行:
npm run eject
將默認(rèn)的一些工程化配置彈出,彈出后項(xiàng)目的目錄會變成這樣:
README.md node_modules package.json scripts
config package-lock.json public src
會比項(xiàng)目剛創(chuàng)建的時(shí)候多了 config 和 scripts 兩個(gè)文件夾,里面是一些腳手架默認(rèn)的工程配置文件,如果對工程化不是非常了解,千萬別亂改!
除了多了兩個(gè)文件目錄之外,在 package.json 中也添加了很多依賴,同時(shí)還添加了 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 的默認(rèn)配置,如果還沒搞懂 babel 和 jest,不要隨意修改歐!
遷移 Jest/Babel 配置
配置都放在 package.json 中不是很方便操作,如果要修改每次都要到 package.json 中去找,我們把他們單獨(dú)抽出來:
首先在根目錄下創(chuàng)建兩個(gè)文件 babel.config.js jest.config.js,然后分別將 package.json 中的 Babel 配置和 Jest 配置復(fù)制到對應(yīng)的 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 配置詳解
我猜,這里肯定又有同學(xué)說:“啊你這個(gè)亂七八糟一大堆配置我怎么看得懂啊~”
別急,讓我們逐條來看!
babel.config.js 的內(nèi)容太簡單,就不詳細(xì)講解了,我們主要來講一講 jest.config.js:
roots: ['<rootDir>/src'],
roots 是用于指定 Jest 的根目錄的,Jest 只會檢測在根目錄下的測試用例并運(yùn)行。
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts'],
src 目錄下文件很多,但是我們要生成測試覆蓋率報(bào)告,一些無關(guān)的文件就不能被統(tǒng)計(jì)到覆蓋率當(dāng)中。
collectCoverageFrom 是用于指定測試覆蓋率統(tǒng)計(jì)范圍的:src 下的所有 js,jsx,ts,tsx 文件,同時(shí)排除 .d.ts 類型聲明文件。
setupFiles: ['react-app-polyfill/jsdom'],
setupFiles 是用于指定創(chuàng)建測試環(huán)境前的準(zhǔn)備文件的,這里引入 react-app-polyfill/jsdom 解決 jsdom 的兼容性問題。
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
setupFilesAfterEnv 是用于指定測試環(huán)境創(chuàng)建完成后為每個(gè)測試文件編寫的配置文件。
我們可以看到默認(rèn)的 setupTests.ts 中內(nèi)容是這樣的:
// 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';
在測試環(huán)境創(chuàng)建完成后為每一個(gè)測試文件都引入 @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 匹配測試文件的規(guī)則的,這里我們看到配置項(xiàng)中填寫的是在 __tests__ 文件夾下的所有 js,jsx,ts,tsx 以及以 .spec/.test 結(jié)尾的 js,jsx,ts,tsx 文件。
testEnvironment: 'jest-environment-jsdom-fourteen',
testEnvironment 應(yīng)該能見名知義了,就是用于指定測試用例運(yùn)動的環(huán)境的。
我們知道 Jest 是運(yùn)行在 node 環(huán)境的,但是我們的前端代碼卻是運(yùn)行在瀏覽器環(huán)境中,因此我們必須使用一些方法在 node 環(huán)境下模擬瀏覽器環(huán)境。
這里 React 官方推薦的是使用 jest-environment-jsdom-fourteen,感興趣的同學(xué)可以去搜索一下這個(gè)庫,現(xiàn)在已經(jīng)有了 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 是用于配置文件處理模塊的。
我們在測試的過程中,其實(shí)是需要去掉 CSS 和其他與組件邏輯相關(guān)性不大的靜態(tài)資源的,但是在我們的組件代碼中有時(shí)候又需要引入這些代碼。
那么在我們測試的時(shí)候,就需要指定一些模塊來處理/代替這些文件,不然就可能以為找不到模塊的問題報(bào)錯(cuò)。
這部分的代碼就是指定了所有的 js,jsx,ts,tsx 使用 babel-jest 的插件做處理,所有的 css 文件使用 <rootDir>/config/jest/cssTransform.js 模塊做處理,所有的非 js,jsx,ts,tsx,css,json 文件,都使用 <rootDir>/config/jest/fileTransform.js 模塊做處理。
那既然用到了這兩個(gè)模塊,我們就到模塊看看 React 官方的配置是什么樣的:
// cssTransform.js
'use strict';
module.exports = {
process() {
return 'module.exports = {};';
},
getCacheKey() {
// The output is always the same.
return 'cssTransform';
}
};
cssTransform.js 模塊中我們可以看到默認(rèn)使用了一個(gè)空的模塊代替 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 模塊中可以看到默認(rèn)的配置是當(dāng)文件名是以 .svg 結(jié)尾時(shí),則創(chuàng)建一個(gè) React 的 SVG 組件并返回,否則直接返回文件名。
transformIgnorePatterns: [
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
'^.+\\.module\\.(css|sass|scss)$'
],
transformIgnorePatterns 用于配置文件處理模塊應(yīng)該忽略的文件,React 官方的配置是忽略 node_modules 文件夾下的所有 js,jsx,ts,tsx,忽略所有的 CSS Module 文件。
modulePaths: [],
modulePaths 用于指定模塊的查找路徑,默認(rèn)會在 node_modules 下查找,如果需要在其他的文件路徑下查找模塊,可以手動指定文件路徑。
moduleNameMapper: {
'^react-native$': 'react-native-web',
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy'
},
moduleNameMapper 用于對模塊映射處理。'^react-native$': 'react-native-web' 是對 React Native 做配置的,Web 應(yīng)用可以刪除,'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy' 是對 CSS Module 做映射,將 CSS Module 轉(zhuǎn)換成鍵值對的形式。
moduleFileExtensions: [
'web.js',
'js',
'web.ts',
'ts',
'web.tsx',
'tsx',
'json',
'web.jsx',
'jsx',
'node'
],
moduleFileExtensions 用于配置需要查找的文件后綴名,如果是 React 的單頁 Web 應(yīng)用,可以刪掉其中非 js,jsx,ts,tsx 的后綴名。
watchPlugins: [
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname'
],
watchPlugins 用于指定 Jest 在 watch 模式下的插件,這部分的配置我們就用 React 官方推薦的就行,基本不需要我們改動。
更多配置
其實(shí) Jest 有很多非常實(shí)用的配置項(xiàng),如果你在測試的過程中遇到問題,不妨嘗試一下閱讀官方的文檔:Jest 官方文檔[9]。
開始動手
在寫這個(gè) Demo 之前想了一下,要是做得太丑了估計(jì)沒人愛看,于是決定做好看點(diǎn),可是我審美又不是很到位,想了想最終決定把 Dell 老師的 TODO List 中的樣式搬過來,邏輯和測試代碼自己寫,站在巨人的肩膀上就好多了。
先來看下效果:
組件代碼我就不詳細(xì)講解了,如果想看組件代碼的話可以到 GitHub 倉庫 clone 下來,然后切換到 advance/testing-library 分支。
我們重點(diǎn)就講解一下如何寫測試的部分!
單元測試(Testing-Library)
首先,我們要分析各個(gè)組件需要測試什么功能,這一點(diǎn)非常重要。
如果一開始沒有明確各個(gè)組件需要測試的功能,很有可能過度測試或者遺漏測試分支!
Header 組件
input存在且value為空input能輸入input能回車提交input能在提交后將value置空List 組件
列表為空,無列表項(xiàng),右上角計(jì)數(shù)器存在且值為 0 列表不為空,存在列表項(xiàng),右上角計(jì)數(shù)器存在且為列表長度,列表項(xiàng)刪除按鈕存在,列表項(xiàng)可刪除 列表不為空,存在列表項(xiàng),右上角計(jì)數(shù)器存在且為列表長度,列表項(xiàng)內(nèi)容點(diǎn)擊后變成 input,回車后可修改對應(yīng)列表項(xiàng)內(nèi)容
明確了我們需要測試的功能,就可以開始寫單元測試的代碼了,測試一下各個(gè)組件是否能夠正常工作:
// 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('輸入框應(yīng)該能輸入', () => {
const inputEvent = {
target: {
value: 'Learn Jest'
}
};
// 模擬輸入
// 輸入后 input value 為輸入值
fireEvent.change(input, inputEvent);
expect(input.value).toEqual(inputEvent.target.value);
});
it('輸入框回車后應(yīng)該能提交并清空', () => {
const inputEvent = {
target: {
value: 'Learn Jest'
}
};
const keyboardEvent = {
keyCode: 13
};
// 模擬回車
// 調(diào)用 addUndoItem props 調(diào)用時(shí)參數(shù)為 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');
// 計(jì)數(shù)器存在且數(shù)值為 0
expect(count).not.toBeNull();
expect(count.textContent).toEqual('0');
const list = wrapper.queryAllByTestId('list-item');
// 列表項(xiàng)為空
expect(list).toHaveLength(0);
});
it('列表項(xiàng)應(yīng)該能刪除', () => {
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');
// 計(jì)數(shù)器存在且數(shù)值為 1
expect(count).not.toBeNull();
expect(count.textContent).toEqual('1');
const list = wrapper.queryAllByTestId('list-item');
// 列表項(xiàng)不為空
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 被調(diào)用 參數(shù)為 0
expect(props.deleteItem).toHaveBeenCalled();
expect(props.deleteItem).toHaveBeenCalledWith(0);
});
it('列表項(xiàng)應(yīng)該能編輯', () => {
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');
// 第一項(xiàng)未處于編輯狀態(tài) 第二項(xiàng)處于編輯狀態(tài)
expect(list[0].querySelector('[data-testid="input"]')).toBeNull();
expect(list[1].querySelector('[data-testid="input"]')).not.toBeNull();
// 點(diǎn)擊第一項(xiàng)
fireEvent.click(list[0]);
// changeStatus 被調(diào)用 參數(shù)為 0
expect(props.changeStatus).toHaveBeenCalled();
expect(props.changeStatus).toHaveBeenCalledWith(0);
// 第二項(xiàng) input 輸入
fireEvent.change(list[1].querySelector('[data-testid="input"]'), {
target: { value: 'Learn Testing Library' }
});
// valueChange 被調(diào)用 參數(shù)為 1 Learn Enzyme
expect(props.valueChange).toHaveBeenCalled();
expect(props.valueChange).toHaveBeenCalledWith(1, 'Learn Testing Library');
// 第二項(xiàng) input 框失焦
fireEvent.blur(list[1].querySelector('[data-testid="input"]'));
// handleBlur 被調(diào)用 參數(shù)為 1
expect(props.handleBlur).toHaveBeenCalled();
expect(props.handleBlur).toHaveBeenCalledWith(1);
});
});
單元測試部分的代碼基本上都是上一篇文章中有介紹到的一些匹配器,沒有看過上一篇或者看了還是一頭霧水的同學(xué)可以回去復(fù)習(xí)一下:《試試前端自動化測試!(基礎(chǔ)篇)》[10],這部分就不詳細(xì)講解了。
集成測試(Testing-Library)
可能有同學(xué)會說:“你上面這個(gè)內(nèi)容測試了單獨(dú)的組件能不能正常工作,萬一他組合起來工作不正常呢?”
如果你看到這里,也有這種感覺的話,就說明你已經(jīng)比較理解自動化測試了。
單元測試有一個(gè)默認(rèn)的前提:如果一份代碼的所有組成部分能夠正常工作,那么這份代碼就能正常工作。
就好像一個(gè)齒輪組,如果齒輪組中的所有齒輪都能正常工作,那么整個(gè)齒輪組就能正常工作。
正常來講,在企業(yè)開發(fā)中,如果我們能夠?qū)崿F(xiàn)較高的單元測試覆蓋率,那么我們就沒有必要再去寫集成測試的代碼,來檢測組合后的代碼是否能正常工作。
但是呢,這里又有點(diǎn)不一樣,為什么這么說呢?
有沒有發(fā)現(xiàn)上面的測試代碼中,并沒有測試Header 組件輸入后 List 組件是否有增加這功能?
所以這里就需要借助集成測試來測試一下兩個(gè)組件組合后,整個(gè)應(yīng)用的功能正不正常:
// 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: '學(xué)習(xí) Jest'
},
{
status: 'div',
value: '學(xué)習(xí) Enzyme'
},
{
status: 'div',
value: '學(xué)習(xí) Testing-Library'
}
],
message: 'success'
}
});
let wrapper: RenderResult;
let headerInput: HTMLInputElement;
let count: HTMLDivElement;
let list: HTMLLIElement[];
let input: HTMLInputElement[];
let deleteBtn: HTMLDivElement[];
// 運(yùn)行每一個(gè)測試用例前先渲染組件
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[];
});
// 運(yùn)行后重置
afterEach(() => {
wrapper = null;
headerInput = null;
count = null;
list = [];
input = [];
deleteBtn = [];
});
describe('App 組件(請求成功時(shí))', () => {
it('組件初始化正常', () => {
// headerInput 存在
expect(headerInput).not.toBeNull();
// 組件初始化 headerInput value 為空
expect(headerInput.value).toEqual('');
// 計(jì)數(shù)器存在且數(shù)值為 3
expect(count).not.toBeNull();
expect(count.textContent).toEqual('3');
// 列表項(xiàng)不為空且長度為 3
expect(list).toHaveLength(3);
// 沒有列表項(xiàng)處于編輯狀態(tài)
expect(input).toHaveLength(0);
});
it('輸入框提交后列表項(xiàng)應(yīng)該增加', () => {
fireEvent.change(headerInput, {
target: { value: '分享自動化測試學(xué)習(xí)成果' }
});
fireEvent.keyUp(headerInput, { keyCode: 13 });
expect(count.textContent).toEqual('4');
// 會觸發(fā) DOM 變化 需重新查詢一次
list = wrapper.queryAllByTestId('list-item') as HTMLLIElement[];
expect(list).toHaveLength(4);
// 最后一項(xiàng)的內(nèi)容為添加的內(nèi)容
expect(list[3]).toHaveTextContent('分享自動化測試學(xué)習(xí)成果');
});
it('列表項(xiàng)刪除后應(yīng)該能減少', () => {
fireEvent.click(deleteBtn[2]);
expect(count.textContent).toEqual('2');
// 會觸發(fā) DOM 變化 需重新查詢一次
list = wrapper.queryAllByTestId('list-item') as HTMLLIElement[];
expect(list).toHaveLength(2);
});
it('列表項(xiàng)應(yīng)該能編輯并提交', () => {
fireEvent.click(list[2]);
const editingItemInput = list[2].querySelector(
'[data-testid="input"]'
) as HTMLInputElement;
// 第一 二項(xiàng)未處于編輯狀態(tài) 第三項(xiàng)處于編輯狀態(tài)
expect(list[0].querySelector('[data-testid="input"]')).toBeNull();
expect(list[1].querySelector('[data-testid="input"]')).toBeNull();
expect(editingItemInput).not.toBeNull();
// 第三項(xiàng)輸入
fireEvent.change(editingItemInput, {
target: { value: 'Learn Testing Library' }
});
expect(editingItemInput.value).toEqual('Learn Testing Library');
// 失焦后內(nèi)容被改變
fireEvent.blur(editingItemInput);
expect(list[2]).toHaveTextContent('Learn Testing Library');
});
});
認(rèn)真看了代碼的同學(xué)可能會對這些代碼感到疑惑:
// mock api
jest.mock('axios');
axios.get.mockResolvedValue({
data: {
code: 200,
data: [
{
status: 'div',
value: '學(xué)習(xí) Jest'
},
{
status: 'div',
value: '學(xué)習(xí) Enzyme'
},
{
status: 'div',
value: '學(xué)習(xí) Testing-Library'
}
],
message: 'success'
}
});
// async render
await act(async () => {
wrapper = render(<App />);
});
因?yàn)槲覀兊膽?yīng)用中用到了 axios 來請求本地?cái)?shù)據(jù),最終呈現(xiàn)在頁面上。
在實(shí)際的開發(fā)當(dāng)中肯定是調(diào)用后端給的接口來獲取數(shù)據(jù)或?qū)?shù)據(jù)進(jìn)行操作,這就引出了很重要的一點(diǎn):測試代碼不能對業(yè)務(wù)代碼產(chǎn)生侵入性!
事實(shí)上這個(gè)侵入性不只是對業(yè)務(wù)代碼的侵入,還有對后端接口的侵入。
上面 mock api 的代碼,其實(shí)就是在測試環(huán)境下模擬一個(gè)接口的返回值,因?yàn)?axios 的 get 方法返回的是一個(gè) Promise,因此我們也應(yīng)該調(diào)用 mockResolvedValue 來模擬接口 resolve 狀態(tài)的結(jié)果。
后面的 async render 其實(shí)也是為這個(gè) axios 服務(wù)的,因?yàn)?axios 請求接口是一個(gè)異步的過程,如果我們使用同步的方式來渲染,異步事件會被放入事件隊(duì)列中,等到同步的代碼執(zhí)行完成。
這樣的話就會出現(xiàn)異步事件還沒執(zhí)行,測試用例就已經(jīng)跑完了的情況,最終導(dǎo)致測試可能不通過。
當(dāng)然,這里只是模擬了接口 resolve 的狀態(tài),我們還可以創(chuàng)建一個(gè) App.rejected.test.tsx 文件,來測試接口 reject 的狀態(tài)(實(shí)際開發(fā)中,接口 reject 可能也需要一個(gè)友好的提示)。
單元測試 & 集成測試(Enzyme)
除了 React 官方推薦的 Testing-Library,Airbnb 公司也推出了一款測試框架 Enzyme,同樣也是非常好用的一款測試框架,設(shè)計(jì)思想略有不同,感興趣的同學(xué)可以到這個(gè)項(xiàng)目的 GitHub 倉庫查看,記得切換到 advance/enzyme 分支。
Enzyme 的代碼就是完全由 Dell 老師編寫的了,這里僅供大家參考學(xué)習(xí)~
單元測試(Hooks 相關(guān))
在使用 React Hooks 開發(fā)的過程中我們可能會將一些重復(fù)邏輯抽離成公共的 Hooks,這公共 Hooks 的可靠性也很重要,Testing-Library 還為我們提供了專門用于測試 React Hooks 的工具:`react-hooks-testing-library`[11]
參考資料
Dell Lee:前端要學(xué)的測試課 從 Jest 入門到 TDD/BDD 雙實(shí)戰(zhàn)[12] jest-dom 官方文檔[13] react-testing-library 官方文檔[14] Enzyme 官方文檔[15]
參考資料
《不想痛失薪資普調(diào)和年終獎(jiǎng)?試試自動化測試!(基礎(chǔ)篇)》: 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 數(shù)據(jù)流實(shí)戰(zhàn)》: 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]《試試前端自動化測試!(基礎(chǔ)篇)》: https://juejin.im/post/5eeae4f7e51d4574195ed982
[11]react-hooks-testing-library: https://github.com/testing-library/react-hooks-testing-library
Dell Lee:前端要學(xué)的測試課 從 Jest 入門到 TDD/BDD 雙實(shí)戰(zhàn): 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/
如果你覺得這篇內(nèi)容對你挺有啟發(fā),我想邀請你幫我三個(gè)小忙:
點(diǎn)個(gè)「在看」,讓更多的人也能看到這篇內(nèi)容(喜歡不點(diǎn)在看,都是耍流氓 -_-) 歡迎加我微信「TH0000666」一起交流學(xué)習(xí)... 關(guān)注公眾號「前端Sharing」,持續(xù)為你推送精選好文。
