全網(wǎng)最細(xì):Jest+Enzyme測(cè)試React組件(包含交互、DOM、樣式測(cè)試)
介紹
Jest是目前前端工程化下單元測(cè)試火熱的技術(shù)棧,而Enzyme的支持提供了Jest測(cè)試React業(yè)務(wù)、組件的能力,下面來介紹一下React組件測(cè)試的一些實(shí)際場(chǎng)景。
1. 測(cè)試依賴包
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"enzyme-to-json": "^3.3.5",
"jest": "^28.1.1",
"jest-less-loader": "^0.1.2",
"jsdom": "^19.0.0", //解決mount渲染組件失敗的BUG,具體見上文
"ts-jest": "^28.0.5",
2. 測(cè)試環(huán)境搭建
由于enzyme的配置在每次需要測(cè)試組件時(shí)都需要加入,因此配置setup.js后在每次測(cè)試組件中提前引入是不錯(cuò)的選擇。
setup.js:
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
const jsdom = require('jsdom');
//解決無法mount渲染組件的問題
const { JSDOM } = jsdom;
const { window } = new JSDOM('');
const { document } = new JSDOM(``).window;
global.document = document;
global.window = window;
//初始化配置
Enzyme.configure({
adapter: new Adapter(),
});
export default Enzyme;
jest.config.js配置:
module.exports = {
transform: {
'^.+\\.(ts|tsx|js|jsx)?$': 'ts-jest',
'\\.(less|css)$': 'jest-less-loader', // 支持less
},
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
};
3. 組件基礎(chǔ)渲染測(cè)試
在為組件添加prop傳值之前,可配置一個(gè)基礎(chǔ)的 mountTest.tsx 來對(duì)組件進(jìn)行一個(gè)基礎(chǔ)渲染掛載測(cè)試,測(cè)試通過后在進(jìn)行復(fù)雜情況下的測(cè)試。
mountTest.tsx
import React from 'react';
import { mount } from 'enzyme';
// 此處Component的類型存在疑問,待完善
export default function mountTest(Component: React.ComponentType<any" data-textnode-index-1701226829723="160" data-index-1701226829723="1307" data-index-len-1701226829723="1307" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> | React.ComponentType) {
describe(`mount and unmount`, () =" data-textnode-index-1701226829723="167" data-index-1701226829723="1369" data-index-len-1701226829723="1369" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
it(`component could be updated and unmounted without errors`, () =" data-textnode-index-1701226829723="173" data-index-1701226829723="1442" data-index-len-1701226829723="1442" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
const wrapper = mount(<Component /" data-textnode-index-1701226829723="177" data-index-1701226829723="1485" data-index-len-1701226829723="1485" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>);
expect(() =" data-textnode-index-1701226829723="180" data-index-1701226829723="1505" data-index-len-1701226829723="1505" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
wrapper.setProps({});
wrapper.unmount();
}).not.toThrow();
});
});
}
4. 組件交互相關(guān)測(cè)試
Button按鈕組件測(cè)試
這里拿Button按鈕舉例,具體Button組件可在http://react-view-ui.com:92/#/common/button參考,底部描述了組件的API能力。
圖片
先看一下Button組件的整體測(cè)試文件,我一共分成了4組測(cè)試用例(不包含mountTest基礎(chǔ)測(cè)試)。
Button.test.tsx
import React from 'react';
import Button from '../../Button/index';
import Enzyme from '../setup';
import mountTest from '../mountTest';
import { act } from 'react-dom/test-utils';
const { shallow, mount } = Enzyme;
mountTest(Button);
describe(`button`, () =" data-textnode-index-1701226829723="230" data-index-1701226829723="2025" data-index-len-1701226829723="2025" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
it('button children show correctly', () =" data-textnode-index-1701226829723="236" data-index-1701226829723="2071" data-index-len-1701226829723="2071" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//按鈕文字內(nèi)容測(cè)試
const component = shallow(<Button" data-textnode-index-1701226829723="242" data-index-1701226829723="2125" data-index-len-1701226829723="2125" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>testButton</Button" data-textnode-index-1701226829723="243" data-index-1701226829723="2144" data-index-len-1701226829723="2144" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>);
const button = component.find('.button');
const p = button.find('button');
expect(p.text()).toBe('testButton');
});
it('click callback correctly', () =" data-textnode-index-1701226829723="248" data-index-1701226829723="2310" data-index-len-1701226829723="2310" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//按鈕點(diǎn)擊回調(diào)測(cè)試
const mockFn = jest.fn();
const component = shallow(<Button handleClick={mockFn} /" data-textnode-index-1701226829723="253" data-index-1701226829723="2416" data-index-len-1701226829723="2416" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>);
const button = component.find('.button');
button.simulate('click');
const mockFnCallLength = mockFn.mock.calls.length;
expect(mockFnCallLength).toBe(0);
act(() =" data-textnode-index-1701226829723="270" data-index-1701226829723="2596" data-index-len-1701226829723="2596" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//測(cè)禁用按鈕
component.setProps({
disabled: true,
});
});
button.simulate('click');
expect(mockFn.mock.calls.length).toBe(mockFnCallLength);
});
it('button type set show correctly color', () =" data-textnode-index-1701226829723="289" data-index-1701226829723="2820" data-index-len-1701226829723="2820" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//測(cè)試按鈕type被賦值className
const component = mount(<Button type="primary" /" data-textnode-index-1701226829723="299" data-index-1701226829723="2901" data-index-len-1701226829723="2901" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>);
expect(component.find('button').hasClass('primary')).toBe(true);
});
it('button loading show correctly', () =" data-textnode-index-1701226829723="312" data-index-1701226829723="3019" data-index-len-1701226829723="3019" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//測(cè)試加載按鈕顯示
const component = mount(<Button type="primary" loading /" data-textnode-index-1701226829723="322" data-index-1701226829723="3096" data-index-len-1701226829723="3096" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>);
expect(component.find('loading1')).not.toBeUndefined();
});
});
從代碼中可以看到,初始化配置一共有如下代碼:
import React from 'react';
import Button from '../../Button/index';
import Enzyme from '../setup';
import mountTest from '../mountTest';
import { act } from 'react-dom/test-utils';
const { shallow, mount } = Enzyme;
mountTest(Button);
主要功能:引入必要的包、引入測(cè)試組件、引入組件渲染方式,這是是shallow和mount兩種,并在最后優(yōu)先進(jìn)行了組件基礎(chǔ)渲染測(cè)試。
第一組測(cè)試用例測(cè)試了Button按鈕的文字顯示正確性,是通過jest的find方法查詢到Button按鈕的DOM元素進(jìn)行判斷;之后設(shè)置了組件的disabled屬性,再次進(jìn)行點(diǎn)擊測(cè)試
it('button children show correctly', () =" data-textnode-index-1701226829723="372" data-index-1701226829723="3612" data-index-len-1701226829723="3612" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//按鈕文字內(nèi)容測(cè)試
const component = shallow(<Button" data-textnode-index-1701226829723="378" data-index-1701226829723="3666" data-index-len-1701226829723="3666" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>testButton</Button" data-textnode-index-1701226829723="379" data-index-1701226829723="3685" data-index-len-1701226829723="3685" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>);
const button = component.find('.button');
const p = button.find('button');
expect(p.text()).toBe('testButton');
});
第二組測(cè)試用例測(cè)試了按鈕的交互,在渲染組件之后,捕捉到按鈕的DOM,并自定義了mockFn函數(shù)傳遞給實(shí)際Button組件后進(jìn)行回調(diào)測(cè)試,Button我在點(diǎn)擊時(shí)是沒有傳參的,因此回調(diào)參數(shù)長度為0
it('click callback correctly', () =" data-textnode-index-1701226829723="389" data-index-1701226829723="3943" data-index-len-1701226829723="3943" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//按鈕點(diǎn)擊回調(diào)測(cè)試
const mockFn = jest.fn();
const component = shallow(<Button handleClick={mockFn} /" data-textnode-index-1701226829723="398" data-index-1701226829723="4049" data-index-len-1701226829723="4049" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>);
const button = component.find('.button');
button.simulate('click');
const mockFnCallLength = mockFn.mock.calls.length;
expect(mockFnCallLength).toBe(0);
act(() =" data-textnode-index-1701226829723="415" data-index-1701226829723="4229" data-index-len-1701226829723="4229" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//測(cè)禁用按鈕
component.setProps({
disabled: true,
});
});
button.simulate('click');
expect(mockFn.mock.calls.length).toBe(mockFnCallLength);
});
第三組測(cè)試用例對(duì)Button按鈕類型進(jìn)行了測(cè)試,傳遞了type:primary,并對(duì)渲染后的組件進(jìn)行判斷是否有primary的類名
it('button type set show correctly color', () =" data-textnode-index-1701226829723="435" data-index-1701226829723="4514" data-index-len-1701226829723="4514" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//測(cè)試按鈕type被賦值className
const component = mount(<Button type="primary" /" data-textnode-index-1701226829723="445" data-index-1701226829723="4595" data-index-len-1701226829723="4595" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>);
expect(component.find('button').hasClass('primary')).toBe(true);
});
第四組測(cè)試用例對(duì)loading Button進(jìn)行了測(cè)試,同樣也是檢查類名的形式,與第三組測(cè)試用例類似
it('button loading show correctly', () =" data-textnode-index-1701226829723="459" data-index-1701226829723="4759" data-index-len-1701226829723="4759" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//測(cè)試加載按鈕顯示
const component = mount(<Button type="primary" loading /" data-textnode-index-1701226829723="469" data-index-1701226829723="4836" data-index-len-1701226829723="4836" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>);
expect(component.find('loading1')).not.toBeUndefined();
});
這就是我對(duì)Button的測(cè)試。
Avatar頭像組件測(cè)試
由于Button組件本身功能比較簡(jiǎn)單,可擴(kuò)展性有限,作為第一個(gè)組件案例進(jìn)行舉例。
接下來對(duì)Avatar組件進(jìn)行測(cè)試。
圖片
還是先上測(cè)試源碼。
Avatar.test.tsx:
import React, { ReactNode } from 'react';
import ReactDOM from 'react-dom';
import Avatar from '../../Avatar/index';
import AvatarGroup from '../../Avatar/group';
import { CameraOutlined } from '@ant-design/icons';
import Enzyme from '../setup';
import mountTest from '../mountTest';
import { act } from 'react-dom/test-utils';
const { mount } = Enzyme;
let container: HTMLDivElement | null;
mountTest(Avatar);
describe('Avatar', () =" data-textnode-index-1701226829723="539" data-index-1701226829723="5435" data-index-len-1701226829723="5435" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//測(cè)試前準(zhǔn)備容器
beforeEach(() =" data-textnode-index-1701226829723="545" data-index-1701226829723="5466" data-index-len-1701226829723="5466" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
container = document.createElement('div');
document.body.appendChild(container);
});
//測(cè)試后刪除容器
afterEach(() =" data-textnode-index-1701226829723="560" data-index-1701226829723="5588" data-index-len-1701226829723="5588" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
document.body.removeChild(container as HTMLDivElement);
container = null;
});
it('test avatar children content show correctly', () =" data-textnode-index-1701226829723="575" data-index-1701226829723="5732" data-index-len-1701226829723="5732" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//測(cè)試頭像文本顯示
let contextText: string | ReactNode = 'test';
const component = mount(<Avatar" data-textnode-index-1701226829723="588" data-index-1701226829723="5833" data-index-len-1701226829723="5833" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>{contextText}</Avatar" data-textnode-index-1701226829723="589" data-index-1701226829723="5855" data-index-len-1701226829723="5855" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>);
expect(component.find('.text-ref').text()).toEqual('test');
const imgSrc =
'https://user-images.githubusercontent.com/9554297/83762004-a0761b00-a6a9-11ea-83b4-9c8ff721d4b8.png';
act(() =" data-textnode-index-1701226829723="605" data-index-1701226829723="6059" data-index-len-1701226829723="6059" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
contextText = <img src={imgSrc}" data-textnode-index-1701226829723="606" data-index-1701226829723="6099" data-index-len-1701226829723="6099" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">></img" data-textnode-index-1701226829723="606" data-index-1701226829723="6105" data-index-len-1701226829723="6105" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>;
});
expect(component.find('img')).toBeDefined();
});
it('test avatar group correctly', () =" data-textnode-index-1701226829723="614" data-index-1701226829723="6207" data-index-len-1701226829723="6207" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//測(cè)試頭像樣式
const component = (
<AvatarGroup size={50} groupStyle={{ margin: '0 10px' }}" data-textnode-index-1701226829723="622" data-index-1701226829723="6307" data-index-len-1701226829723="6307" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>
<Avatar style={{ background: 'rgb(20, 169, 248)' }} shape="square"" data-textnode-index-1701226829723="631" data-index-1701226829723="6382" data-index-len-1701226829723="6382" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>
View
</Avatar" data-textnode-index-1701226829723="633" data-index-1701226829723="6413" data-index-len-1701226829723="6413" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>
<Avatar style={{ background: 'rgb(51, 112, 255)' }}" data-textnode-index-1701226829723="642" data-index-1701226829723="6473" data-index-len-1701226829723="6473" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>React</Avatar" data-textnode-index-1701226829723="642" data-index-1701226829723="6487" data-index-len-1701226829723="6487" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>
<Avatar style={{ background: 'rgb(0, 208, 184)' }}" data-textnode-index-1701226829723="651" data-index-1701226829723="6546" data-index-len-1701226829723="6546" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>UI</Avatar" data-textnode-index-1701226829723="651" data-index-1701226829723="6557" data-index-len-1701226829723="6557" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>
</AvatarGroup" data-textnode-index-1701226829723="652" data-index-1701226829723="6577" data-index-len-1701226829723="6577" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>
);
act(() =" data-textnode-index-1701226829723="654" data-index-1701226829723="6596" data-index-len-1701226829723="6596" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
ReactDOM.render(component, container);
});
const avatarStyleList = [
{
background: 'rgb(20, 169, 248)',
content: 'View',
},
{
background: 'rgb(51, 112, 255)',
content: 'React',
},
{
background: 'rgb(0, 208, 184)',
content: 'UI',
},
];
const groupDom = (container as HTMLDivElement).querySelector('.avatar-group') as HTMLElement;
expect(groupDom.childElementCount).toBe(3);
const avatars = Array.from((container as HTMLDivElement).querySelectorAll('.avatar'));
avatars.forEach((avatar, index) =" data-textnode-index-1701226829723="708" data-index-1701226829723="7191" data-index-len-1701226829723="7191" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//測(cè)試頭像組的每個(gè)頭像樣式
expect(
avatar
.getAttribute('style')
?.includes(`background: ${avatarStyleList[index].background}`) &&
avatar.querySelector('.text-ref')?.innerHTML === avatarStyleList[index].content,
).toBe(true);
if (index === 0) {
//測(cè)試頭像形狀
expect(avatar.getAttribute('style')?.includes(`border-radius: 5px`)).toBe(true);
}
});
});
it('test avatar click callback correctly', () =" data-textnode-index-1701226829723="730" data-index-1701226829723="7653" data-index-len-1701226829723="7653" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//頭像點(diǎn)擊交互測(cè)試
const mockFn = jest.fn();
const component = mount(
<Avatar
size={54}
triggerType="mask"
triggerIcon={<CameraOutlined style={{ fontSize: '20px' }} /" data-textnode-index-1701226829723="740" data-index-1701226829723="7850" data-index-len-1701226829723="7850" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>}
triggerClick={mockFn}
" data-textnode-index-1701226829723="742" data-index-1701226829723="7887" data-index-len-1701226829723="7887" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>
<img src="https://user-images.githubusercontent.com/9554297/83762004-a0761b00-a6a9-11ea-83b4-9c8ff721d4b8.png"" data-textnode-index-1701226829723="743" data-index-1701226829723="8006" data-index-len-1701226829723="8006" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">></img" data-textnode-index-1701226829723="743" data-index-1701226829723="8012" data-index-len-1701226829723="8012" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>
</Avatar" data-textnode-index-1701226829723="744" data-index-1701226829723="8027" data-index-len-1701226829723="8027" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>,
);
act(() =" data-textnode-index-1701226829723="746" data-index-1701226829723="8047" data-index-len-1701226829723="8047" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
component.simulate('click');
});
let mockFnCallLength = mockFn.mock.calls.length;
expect(mockFnCallLength).toBe(0);
act(() =" data-textnode-index-1701226829723="753" data-index-1701226829723="8192" data-index-len-1701226829723="8192" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
component.setProps({
triggerType: 'button',
});
});
component.update();
mockFnCallLength = mockFn.mock.calls.length;
expect(mockFnCallLength).toBe(0);
});
});
拆解一下組件的源碼,測(cè)試最初的操作如下:
import React, { ReactNode } from 'react';
import ReactDOM from 'react-dom';
import Avatar from '../../Avatar/index';
import AvatarGroup from '../../Avatar/group';
import { CameraOutlined } from '@ant-design/icons';
import Enzyme from '../setup';
import mountTest from '../mountTest';
import { act } from 'react-dom/test-utils';
const { mount } = Enzyme;
let container: HTMLDivElement | null;
mountTest(Avatar);
和Button的測(cè)試區(qū)別點(diǎn)其實(shí)就在,定義了container容器,用于接下來的DOM測(cè)試。
//測(cè)試前準(zhǔn)備容器
beforeEach(() =" data-textnode-index-1701226829723="825" data-index-1701226829723="8874" data-index-len-1701226829723="8874" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
container = document.createElement('div');
document.body.appendChild(container);
});
//測(cè)試后刪除容器
afterEach(() =" data-textnode-index-1701226829723="840" data-index-1701226829723="8996" data-index-len-1701226829723="8996" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
document.body.removeChild(container as HTMLDivElement);
container = null;
});
在進(jìn)行測(cè)試用例之前,創(chuàng)建了一個(gè)空div作為React測(cè)試的容器,放置React組件,并在測(cè)試用例結(jié)束后對(duì)該容器進(jìn)行清除。
接下來我們開始分析測(cè)試用例:
第一組測(cè)試用例測(cè)試了文本頭像和圖片頭像的顯示正確性,首先給組件傳遞了一個(gè)test文本值,對(duì)文本值進(jìn)行判斷。之后又給組件傳遞了一張圖片(ReactNode),并對(duì)組件中的圖片進(jìn)行查詢判斷。
it('test avatar children content show correctly', () =" data-textnode-index-1701226829723="858" data-index-1701226829723="9305" data-index-len-1701226829723="9305" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//測(cè)試頭像文本顯示
let contextText: string | ReactNode = 'test';
const component = mount(<Avatar" data-textnode-index-1701226829723="871" data-index-1701226829723="9406" data-index-len-1701226829723="9406" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>{contextText}</Avatar" data-textnode-index-1701226829723="872" data-index-1701226829723="9428" data-index-len-1701226829723="9428" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>);
expect(component.find('.text-ref').text()).toEqual('test');
const imgSrc =
'https://user-images.githubusercontent.com/9554297/83762004-a0761b00-a6a9-11ea-83b4-9c8ff721d4b8.png';
act(() =" data-textnode-index-1701226829723="888" data-index-1701226829723="9632" data-index-len-1701226829723="9632" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
contextText = <img src={imgSrc}" data-textnode-index-1701226829723="889" data-index-1701226829723="9672" data-index-len-1701226829723="9672" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">></img" data-textnode-index-1701226829723="889" data-index-1701226829723="9678" data-index-len-1701226829723="9678" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>;
});
expect(component.find('img')).toBeDefined();
});
第二組測(cè)試用例較為復(fù)雜,沒有通過jest的渲染方式渲染組件,而是用上了之前所講到的container容器,并且創(chuàng)建了一個(gè)React虛擬DOM,渲染在測(cè)試用例環(huán)境中。這樣做其實(shí)也是因?yàn)闇y(cè)試用例本身是需要測(cè)試不同情況下的頭像樣式是否生效,因此會(huì)用到這種渲染方式。
it('test avatar group correctly', () =" data-textnode-index-1701226829723="900" data-index-1701226829723="9905" data-index-len-1701226829723="9905" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//測(cè)試頭像樣式
const component = (
<AvatarGroup size={50} groupStyle={{ margin: '0 10px' }}" data-textnode-index-1701226829723="911" data-index-1701226829723="10005" data-index-len-1701226829723="10005" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>
<Avatar style={{ background: 'rgb(20, 169, 248)' }} shape="square"" data-textnode-index-1701226829723="916" data-index-1701226829723="10080" data-index-len-1701226829723="10080" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>
View
</Avatar" data-textnode-index-1701226829723="919" data-index-1701226829723="10111" data-index-len-1701226829723="10111" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>
<Avatar style={{ background: 'rgb(51, 112, 255)' }}" data-textnode-index-1701226829723="920" data-index-1701226829723="10171" data-index-len-1701226829723="10171" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>React</Avatar" data-textnode-index-1701226829723="921" data-index-1701226829723="10185" data-index-len-1701226829723="10185" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>
<Avatar style={{ background: 'rgb(0, 208, 184)' }}" data-textnode-index-1701226829723="924" data-index-1701226829723="10244" data-index-len-1701226829723="10244" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>UI</Avatar" data-textnode-index-1701226829723="925" data-index-1701226829723="10255" data-index-len-1701226829723="10255" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>
</AvatarGroup" data-textnode-index-1701226829723="927" data-index-1701226829723="10275" data-index-len-1701226829723="10275" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>
);
act(() =" data-textnode-index-1701226829723="931" data-index-1701226829723="10294" data-index-len-1701226829723="10294" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
ReactDOM.render(component, container);
});
const avatarStyleList = [
{
background: 'rgb(20, 169, 248)',
content: 'View',
},
{
background: 'rgb(51, 112, 255)',
content: 'React',
},
{
background: 'rgb(0, 208, 184)',
content: 'UI',
},
];
const groupDom = (container as HTMLDivElement).querySelector('.avatar-group') as HTMLElement;
expect(groupDom.childElementCount).toBe(3);
const avatars = Array.from((container as HTMLDivElement).querySelectorAll('.avatar'));
avatars.forEach((avatar, index) =" data-textnode-index-1701226829723="987" data-index-1701226829723="10889" data-index-len-1701226829723="10889" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//測(cè)試頭像組的每個(gè)頭像樣式
expect(
avatar
.getAttribute('style')
?.includes(`background: ${avatarStyleList[index].background}`) &&
avatar.querySelector('.text-ref')?.innerHTML === avatarStyleList[index].content,
).toBe(true);
if (index === 0) {
//測(cè)試頭像形狀
expect(avatar.getAttribute('style')?.includes(`border-radius: 5px`)).toBe(true);
}
});
});
通過ReactDOM.render渲染后,首先獲取了所有頭像的最外層容器:groupDom,并對(duì)頭像組所包含的頭像元素長度進(jìn)行判斷,我這里是傳了三個(gè)頭像,因此預(yù)期應(yīng)該為3。
const groupDom = (container as HTMLDivElement).querySelector('.avatar-group') as HTMLElement;
expect(groupDom.childElementCount).toBe(3);
接下來獲取了所有頭像的DOM,并進(jìn)行遍歷判斷,判斷自定義的頭像背景顏色和所傳文本內(nèi)容是否相同,兩者都滿足,則該頭像的測(cè)試通過;并在我對(duì)第一個(gè)頭像設(shè)置了shape: square,這代表了這是一個(gè)方形頭像,因此在遍歷中需要對(duì)第一個(gè)頭像單獨(dú)做一次測(cè)試,判斷它的樣式是否生效(圓角)
avatars.forEach((avatar, index) =" data-textnode-index-1701226829723="1042" data-index-1701226829723="11695" data-index-len-1701226829723="11695" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//測(cè)試頭像組的每個(gè)頭像樣式
expect(
avatar
.getAttribute('style')
?.includes(`background: ${avatarStyleList[index].background}`) &&
avatar.querySelector('.text-ref')?.innerHTML === avatarStyleList[index].content,
).toBe(true);
if (index === 0) {
//測(cè)試頭像形狀
expect(avatar.getAttribute('style')?.includes(`border-radius: 5px`)).toBe(true);
}
});
如上就是第二組測(cè)試用例,和之前測(cè)試用例不同的無非就是渲染方式和組件的樣式判斷,使用了原生的一些判斷,最后通過jest的toBe方法進(jìn)行斷言。
第三組測(cè)試用例是交互測(cè)試,在對(duì)頭像設(shè)置了triggerIcon、triggerType、triggerClick后可變成交互頭像,具體顯示可查看組件庫文檔-Avatar頭像。這里也是先定義了一個(gè)mock函數(shù),傳遞給組件作為回調(diào)函數(shù)測(cè)試,并且整體測(cè)試了mask、button兩種交互頭像的回調(diào)正確性
it('test avatar click callback correctly', () =" data-textnode-index-1701226829723="1088" data-index-1701226829723="12368" data-index-len-1701226829723="12368" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
//頭像點(diǎn)擊交互測(cè)試
const mockFn = jest.fn();
const component = mount(
<Avatar
size={54}
triggerType="mask"
triggerIcon={<CameraOutlined style={{ fontSize: '20px' }} /" data-textnode-index-1701226829723="1106" data-index-1701226829723="12565" data-index-len-1701226829723="12565" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>}
triggerClick={mockFn}
" data-textnode-index-1701226829723="1108" data-index-1701226829723="12602" data-index-len-1701226829723="12602" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>
<img src="https://user-images.githubusercontent.com/9554297/83762004-a0761b00-a6a9-11ea-83b4-9c8ff721d4b8.png"" data-textnode-index-1701226829723="1111" data-index-1701226829723="12721" data-index-len-1701226829723="12721" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">></img" data-textnode-index-1701226829723="1112" data-index-1701226829723="12727" data-index-len-1701226829723="12727" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>
</Avatar" data-textnode-index-1701226829723="1114" data-index-1701226829723="12742" data-index-len-1701226829723="12742" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">>,
);
act(() =" data-textnode-index-1701226829723="1118" data-index-1701226829723="12762" data-index-len-1701226829723="12762" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
component.simulate('click');
});
let mockFnCallLength = mockFn.mock.calls.length;
expect(mockFnCallLength).toBe(0);
act(() =" data-textnode-index-1701226829723="1132" data-index-1701226829723="12907" data-index-len-1701226829723="12907" class="" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; display: inline-block; text-indent: initial;">> {
component.setProps({
triggerType: 'button',
});
});
component.update();
mockFnCallLength = mockFn.mock.calls.length;
expect(mockFnCallLength).toBe(0);
});
如上就是頭像組件的所有測(cè)試用例。
小結(jié)
測(cè)試React組件無非就是測(cè)試其交互性和樣式渲染正確性,因此筆者在React組件測(cè)試中使用最頻繁的就是文中所述的兩種渲染形式
- Jest渲染(mount、render、shallow)
- ReactDOM渲染(用于測(cè)試樣式、元素節(jié)點(diǎn))
因此掌握了這兩種渲染形式去書寫測(cè)試用例,可以測(cè)試到大部分的組件業(yè)務(wù)場(chǎng)景,在組件上線之前mock出更多的場(chǎng)景來避免錯(cuò)誤發(fā)生。