什么是测试用例
字面理解来说,就是测试一个东西,对于我们前端开发来说就是测试前端功能,前端主要分为 html css javascript,一般来说是测试 js,现在主要通过模块的形式来开发,对于 react 来说,html 也属于 js 的一部分,css 怎么测试,或许你需要判断渲染的组件,有没有className。
测试用例的作用是什么
举个例子来说明测试用例的简单场景
对于刚接触测试用例的来说,会不会觉得很浪费时间,我的功能都可以正常运行为什么要没事干去写测试用例,干点其他的不好吗。其实这样想是正常的,但是到后面开发某些工具方法或者通用组件的时候就会体现测试用例的重要性了。
例:(环境搭建后面会提,这里需要先搞定测试用例是什么)
现在有 sum 方法,就是一个简单的计算,App 组件,现在功能正常运行,渲染为 3 ,预期很符合我们想要的。这个测试用例就是用来保证这个函数的输入输出是符合预期的。
export default function sum(a, b) {
return a + b;
}
function App() {
return (
<div>
{sum(1, 2)}
</div>
);
}
测试用例代码,先简单的看一下大概语法,
// describe 和 test 先不看
describe('测试sum方法', () => {
test('测试 1 + 2 = 3', () => {
// expect 来源于 jest 语法,expect 就是拿到一个值,这里可以是函数调用,也可以是常量
// toBe() 是判断方式的一种方式,
// 按照代码来理解就是 sum(1, 2) === 3
// jest文档 https://jestjs.io/docs/en/expect
expect(sum(1, 2)).toBe(3);
});
});
测试用例运行结果
image.png但是到后面功能开发的时候,可能会使用后台返回的数据来进行渲染,如果现在的数据是这样
const testData = {
a: '1',
b: '2'
};
function App() {
return (
<div>
{sum(testData.a, testData.b)}
</div>
);
}
后台给的数据是字符串,我们确直接使用了,现在得到 '12' ,肯定是不符合预期的,有人说可以用 ts 来,虽然 ts 确实可以避免这个问题,但是大家知道代码量也会上升,这里不考虑 ts 在的情况下。
这个时候大家会想到使用Number等转换为数字的方法来保证功能的正常运行,所以现在我们需要来对 sum 进行改造,大家要知道改造就有可能出问题,现在我们来进行简单修改一下
export default function sum(a, b) {
return Number(a) + Number(b);
}
测试用例也需要同步添加
describe('测试sum方法', () => {
test('测试 1 + 2 = 3', () => {
expect(sum(1, 2)).toBe(3);
});
test('测试字符串相加', () => {
expect(sum('1', '2')).toBe(3);
});
});
现在功能正常运行,但是实际上这里还有问题,如果给的是 '' undefined null 等这里又会有问题
我们这个功能需要兼容各种场景,并不是只能适用于一个需求,现在我们重新改造方法
const parseValue = value => Number.isNaN(Number(value)) ? 0 : Number(value);
export default function sum(a, b) {
return parseValue(a) + parseValue(b);
}
const testData = {
a: '1',
b: undefined
};
function App() {
return (
<div>
{sum(testData.a, testData.b)}
</div>
);
}
describe('测试sum方法', () => {
test('测试 1 + 2 = 3', () => {
expect(sum(1, 2)).toBe(3);
});
test('测试字符串相加', () => {
expect(sum('1', '2')).toBe(3);
});
test('测试空值相加', () => {
expect(sum(null, undefined)).toBe(0);
expect(sum('', undefined)).toBe(0);
});
});
image.png
现在感觉这个看起来可以正常运行
但是我们需求总是不同的,比如现在是只支持两个参数,后面多个参数也是有可能的,这个时候就需要保证改造的同时支持原来的逻辑不受影响。到目前为止我们的测试用例仍然是不会出错的。如果这个方法很复杂,别人不小心把 + 改成了 - 或者 * ,那么这个时候在去执行测试用例就能找到问题,由于新的改动影响到了之前的测试用例,那么说明这次的改动是有问题的,能及时发现问题。
const parseValue = value => Number.isNaN(Number(value)) ? 0 : Number(value);
export default function sum(a, b) {
return parseValue(a) - parseValue(b);
}
image.png
所以测试用例就是用来保证功能的正常执行,如果测试用例写的场景较少,改成下面这种也是发现不了问题的,如果这里传入 0.1 + 0.2 === 0.3 吗
describe('测试sum方法', () => {
test('测试空值相加', () => {
expect(sum(null, undefined)).toBe(0);
expect(sum('', undefined)).toBe(0);
});
});
image.png
如果你的方法写了足够全的测试用例,即使别人来改造你这个方法,也能尽快定位问题并回归功能,而且测试用例还能定位到没有执行的代码,删除不用的代码,建议每次 build 的时候运行一次测试用例,避免出现问题(我写的代码天下第一,没有bug,不需要单测)
image.png
这里提示第6行代码没有被执行到,所以需要考虑方法里面的各种分支也是有用的,打包优化也是必备的。
测试用例要写很多
其实是这样的,为了避免线上问题,这是很有必要的,因为很多组件或方法带来的影响不仅仅只有一个地方。对于一些业务页面,通常变化过快,编写成本过高,可以多测试页面的功能,回归之前的功能(有问题测试背锅)来代替测试用例的编写,如果你的时间足够充足可以上 ts + 测试用例(那我有时间为啥不弄点其他的)。
现在我们来进行react中的测试用例
这里采用 npx create-react-app test-app 来创建一个项目
脚手架已经内置了 jest 来进行测试,如果你说为什么是 jest ,哪请去问 create-react-app,
如果你是自己用 webpack 来搭建的项目,或者dva,请参考
jest配置 https://jestjs.io/docs/en/configuration (这或许会比较麻烦,如果是项目初期可以,如果是项目后期需要加测试用例,需要处理各种环境问题)
我想这是大家都会的(https://www.html.cn/create-react-app/docs/running-tests/)
- npx create-react-app test-app
- cd test-app
- npm start
- npm install --save enzyme enzyme-adapter-react-16 react-test-renderer
- 在 src/setupTests.js 添加以下代码
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
configure({ adapter: new Adapter() });
来源于 create-react-app 建议
文件名约定
Jest 将使用以下任何流行的命名约定来查找测试文件:
tests 文件夹中带有 .js 后缀的文件。
带有 .test.js 后缀的文件。
带有 .spec.js 后缀的文件。
.test.js / .spec.js 文件(或 tests 文件夹)可以是位于顶级文件夹 src 下任何深度的文件夹中。
我们建议将测试文件(或 tests 文件夹)放在他们正在测试的代码旁边,以便相对路径导入时路径更短。 例如,如果 App.test.js 和 App.js 位于同一文件夹中,则测试只需从 import App from './App' 而不是很长的相对路径。 主机托管的情况下还有助于在大型项目中更快地找到测试。
基本库
- 对于断言库使用 jest https://jestjs.io/docs/en/configuration ,这个可以用来测试代码执行的结果,代码执行了到了什么分支,什么没有执行到等等。
- 对于 react 测试采用 enzyme、react-testing-library
这都是可以用来测试react,只是语法api不同而且,建议了解一下在选择,因为测试用例是运行在node 环境下的,需要一个渲染的环境,而这个就可以在node环境里面执行你的react代码。
- src/utils/sum 下新建文件 index.js index.test.js
export default function sum(a, b) {
return a + b;
}
import sum from './index';
describe('测试sum方法', () => {
test('测试 1 + 2 = 3', () => {
expect(sum(1, 2)).toBe(3);
});
});
-
运行 npm test,如果得到以下信息说明目前环境没有问题
image.png
目前目录结构(文件颜色是因为git的关系)
image.png
现在来分析测试用例语法 https://jestjs.io/docs/en/getting-started
// describe 这个是 jest 库方法,创建一个将几个相关测试组合在一起的模块
// test 运行测试的方法,等同于 it
test('测试 1 + 2 = 3', () => {
// expect 返回一个 jest 对象,可以调用jest匹配器,
// 例如:
// toBe(3) 就是等于 3
// toBe(true) 就是等于 true
// toBe(false) 就是等于 false
// toBeNull 仅匹配 null
// toBeUndefined 仅匹配 undefined
// toBeTruthy 匹配if语句视为真实的任何内容
// toBeFalsy 匹配if语句视为假的任何内容
expect(sum(1, 2)).toBe(3);
});
// test === it
it('测试 1 + 2 = 3', () => {
expect(sum(1, 2)).toBe(3);
});
是不是很简单,就是执行前面的代码逻辑,后面来判断是不是相等,更多api还需要去啃jest文档。
异步和mock等等
enzyme 对于react我们来新建几个文件
src/components/Counter index.js index.test.js
import React, { PureComponent } from 'react';
class Index extends PureComponent {
state = {
value: 0
};
addition = () => {
this.setState(({ value }) => ({ value: value + 1 }));
};
subtraction = () => {
this.setState(({ value }) => ({ value: value - 1 }));
};
render() {
const { value } = this.state;
return (
<div>
<button onClick={this.addition}>+</button>
<span>{value}</span>
<button onClick={this.subtraction}>-</button>
</div>
)
}
}
export default Index;
import React from 'react';
import { shallow } from 'enzyme';
import Counter from './index';
describe('测试 Counter 组件', () => {
test('测试渲染内容', () => {
// shallow 这个方法返回一个react组件包装对象,可以调用 enzyme 内置方法
// 这是一个简单的浅渲染,就是说只是渲染当前组件的内容,假如组件里面套用了很多其他组件,
// 就不会被渲染到,import { mount } from 'enzyme'; 这个可以完整渲染,需要根据自己场景来。
const wrapper = shallow(<Counter />);
// wrapper.debug() 这个打印渲染组件内容,可以用来调试。
console.log(wrapper.debug());
// wrapper.state() 获取一个组件的 state,然后在用jest来断言,就是判断初始化组件的state.value === 0
expect(wrapper.state().value).toBe(0);
// find('div') 方法是选择器,类似于 document.querySelector document.querySelectorAll
// https://enzymejs.github.io/enzyme/docs/api/selector.html
expect(wrapper.find('div')).not.toBeNull();
expect(wrapper.find('button').length).toBe(2);
expect(wrapper.find('span').length).toBe(1);
});
test('测试添加', () => {
const wrapper = shallow(<Counter />);
const additionButton = wrapper.find('button').at(0);
/*
simulate 这个方法是用来主动触发一个元素的事件,
因为我们在测试用例的环境里面不可能去手动点击是吧,
就算可以点击,也不可能每次跑测试用例来点点点吧。
这个操作就是 enzyme 会去执行 click 事件,可以传入两个参数
第一个参数是事件类型,第二个是事件参数,比如 { target: { value:'1' } }
然后调用的方法可以使用这个参数,然后我们在重新获取到最新的state进行判断,
当然了如果你调用两次哪判断条件就要改变,这里会想,setState 是异步的,
为什么这里立马触发了 setState 就可以获取到最新值呢,这或许需要深入研究 enzyme 是怎么实现的。
*/
additionButton.simulate('click');
expect(wrapper.state().value).toBe(1);
});
test('测试减少', () => {
const wrapper = shallow(<Counter />);
const subtractionButton = wrapper.find('button').at(1);
subtractionButton.simulate('click');
expect(wrapper.state().value).toBe(-1);
});
});
运行 npm test -- src/components/Counter/index.test.js,注意别忘了 -- 之间的空格
这是npm 命令传参,jest 可以接收到这个参数,并且只运行这一个文件
现在有一个 计数器(Counter )组件,一个简单的内部状态,+ - 和展示
但是写测试用例应该从比较底层的组件开始写,如果一开始就要以大组件来编写,可以考虑采用 jest mock 方法来模拟一个简单的组件。
然后进行判断数量是不是我们预期的,当然了也不用逐个判断,
使用 expect(wrapper.isEmptyRender()).toBeFalsy(); 来判断是不是返回空也行,所以有时候即使测试用例
100%覆盖率也有可能会有问题,还得看测试用例是怎么去写的
运行 npm test -- --coverage 可以查看覆盖率,执行了那些代码
并且在package.json添加以下配置,这代表我们只收集 components 和 utils 下面的文件
https://www.html.cn/create-react-app/docs/running-tests/
"jest": {
"collectCoverageFrom": [
"<rootDir>/src/components/**/*.{js,jsx,ts,tsx}",
"<rootDir>/src/utils/**/*.{js,jsx,ts,tsx}"
]
}
如果你在这一步出错了,请删除 package-lock.json 或 yarn-lock.json 文件,在删除 node_modules 文件夹,并重新 cnpm install,这可能是脚手架的bug
image.png这就是一个简单的组件渲染,并且我们触发了事件。现在测试覆盖率是 100%
Statements、Branches 、Functions、 Lines 、Uncovered Line
语句、分支、函数、行、未覆盖线
然后在项目根目录会生成一个 coverage 文件,里面会有代码覆盖率。
怎样看没执行的代码呢,我们现在注释掉以下代码
// test('测试添加', () => {
// const wrapper = shallow(<Counter />);
// const additionButton = wrapper.find('button').at(0);
// additionButton.simulate('click');
// expect(wrapper.state().value).toBe(1);
// });
现在重新生成报告,可以看到,函数覆盖率为 60,第10行代码没执行到
image.png另外用浏览器打开 coverage\lcov-report\components\Counter\index.js.html 文件(如果不打开这个文件,是无法找到函数的调用,另外将coverage目录添加到.gitignore里面,防止git提交),可以看到红色的就是没有被执行到的,这里有一个 addition 方法没有被执行到,第10行代码没有被执行到,需要修改测试用例的执行。这里可以得知,这个是基于我们写的测试用例代码来判断这个组件的功能,不是实际应用的场景,如果你的功能就这两个,又被添加了其他方法没有使用,那么这个时候把没有使用的代码进行删除。
image.png
现在添加一个test方法并重新执行,可以看到 test 方法标红了,这是多余的方法,并且 Funcs 覆盖率为 83
image.png image.png
现在添加两个生命周期进行测试,测试用例不变,说明 componentDidUpdate 是生效了的,因为我们触发了
事件,会去setState,如果我们没有去写按钮的测试用例,那么这个方法就是无用的,componentDidCatch 是因为组件没有被出错,所以没有被调用到。
image.png image.png
基本上这是一个react简单的测试用例了,enzyme api还有很多,写之前尽量看了文档在入手,会少走很多弯路。https://enzymejs.github.io/enzyme/docs/api/
对于网络请求,这是我们需要mock的
jest https://jestjs.io/docs/en/bypassing-module-mocks
axios可以参考 https://www.robinwieruch.de/axios-jest
fetch 可以参考 https://medium.com/@rishabhsrao/mocking-and-testing-fetch-with-jest-c4d670e2e167
我们在src下新建services/index.js 文件
需要先 cnpm install --save axios
services/index.js
import axios from 'axios';
export const counterValue = () => {
return axios.get('/counterValue.json');
};
我们继续添加一个生命周期 componentDidMount 初始化的时候去网络请求数据,并且设置为我们的初始值。
import React, { PureComponent } from 'react';
import { counterValue } from '../../services'
class Index extends PureComponent {
state = {
value: 0
};
async componentDidMount() {
await this.fetchData();
}
componentDidUpdate() {
console.log('更新');
}
fetchData = async () => {
const { data, success } = await counterValue();
success && this.setState({ value: data });
};
addition = () => {
this.setState(({ value }) => ({ value: value + 1 }));
};
subtraction = () => {
this.setState(({ value }) => ({ value: value - 1 }));
};
render() {
const { value } = this.state;
return (
<div>
<button onClick={this.addition}>+</button>
<span>{value}</span>
<button onClick={this.subtraction}>-</button>
</div>
)
}
}
export default Index;
那么这个时候我们的组件有了请求,但实际跑测试用例的时候是不需要去真正的请求的,所以我们需要自己来伪造这个请求。修改一下测试用例代码
import React from 'react';
import { shallow } from 'enzyme';
import axios from 'axios';
import Counter from './index';
// jest 内部对 axios 进行一些特殊处理
jest.mock('axios');
// 接口成功返回值
const successResult = {
success: true,
data: 2
};
// 接口失败返回值
const errorResult = {
success: false
};
describe('测试 Counter 组件', () => {
// 在运行此文件中的每个测试之前运行一个函数
beforeEach(() => {
// 自己来mock axios.get 的返回值,避免网络请求
axios.get.mockReturnValue(new Promise((resolve) => resolve(successResult)));
});
// 注意这里添加了 async , 因为我们的组件 componentDidMount 的时候去发起了请求
// 所以这里需要异步,然后在进行后续操作,如果没有写 async await ,那么这里渲染的就是初始值
test('测试渲染内容,初始化接口成功', async () => {
const wrapper = await shallow(<Counter />);
expect(wrapper.state().value).toBe(2);
expect(wrapper.find('div')).not.toBeNull();
expect(wrapper.find('button').length).toBe(2);
expect(wrapper.find('span').length).toBe(1);
});
// 测试接口失败的渲染
test('测试渲染内容,初始化接口失败', async () => {
axios.get.mockReturnValue(new Promise((resolve) => resolve(errorResult)));
const wrapper = await shallow(<Counter />);
expect(wrapper.state().value).toBe(0);
});
// 触发事件也是需要这样
test('测试添加', async () => {
const wrapper = await shallow(<Counter />);
const additionButton = wrapper.find('button').at(0);
additionButton.simulate('click');
expect(wrapper.state().value).toBe(successResult.data + 1);
});
test('测试减少', async () => {
const wrapper = await shallow(<Counter />);
const subtractionButton = wrapper.find('button').at(1);
subtractionButton.simulate('click');
expect(wrapper.state().value).toBe(successResult.data - 1);
});
});
react-testing-library
https://create-react-app.dev/docs/running-tests
https://testing-library.com/docs/react-testing-library/api#asfragment
在哪之前需要安装
npm install --save @testing-library/react @testing-library/jest-dom
src/setupTests.js
import '@testing-library/jest-dom/extend-expect';
修改 src/components/Counter/index.js 为之前的代码
import React, { PureComponent } from 'react';
class Index extends PureComponent {
state = {
value: 0
};
componentDidUpdate(prevProps, prevState, snapshot) {
console.log(prevState);
}
addition = () => {
this.setState(({ value }) => ({ value: value + 1 }));
};
subtraction = () => {
this.setState(({ value }) => ({ value: value - 1 }));
};
render() {
const { value } = this.state;
return (
<div>
<button onClick={this.addition}>+</button>
<span>{value}</span>
<button onClick={this.subtraction}>-</button>
</div>
)
}
}
export default Index;
测试用例
import React from 'react';
// 这里导包的文件修改
import { render } from '@testing-library/react';
import Counter from './index';
describe('测试 Counter 组件', () => {
test('测试渲染内容', () => {
// 该render方法返回一个具有一些属性的对象
// container.querySelector 就是原生js的查找方法,这里来查找到是否有这些元素
// 如果你想判断 state 或许需要使用 ref 来拿到react实例
// <Counter ref={xxx} /> ref 是你的变量
const { container } = render(<Counter />);
expect(container.querySelector('div')).not.toBe(null);
expect(container.querySelectorAll('button').length).toBe(2);
expect(container.querySelectorAll('span').length).toBe(1);
});
test('测试添加', () => {
const { container } = render(<Counter />);
const additionButton = container.querySelectorAll('button')[0];
// 这里查找到该按钮,然后手动触发了 .click() 事件,这不是testing-library的方法,这就是一个
// 原生对象自带的方法
additionButton.click();
expect(container.querySelector('span').innerHTML).toBe('1');
});
test('测试减少', () => {
const { container } = render(<Counter />);
const subtractionButton = container.querySelectorAll('button')[1];
subtractionButton.click();
// 这里查找到span元素,判断 innerHTML 值
expect(container.querySelector('span').innerHTML).toBe('-1');
subtractionButton.click();
expect(container.querySelector('span').innerHTML).toBe('-2');
});
});
这个和 enzyme 或许概念上有些不同,但是两者都是可以进行测试用例,testing-library 不止支持 react。
对于接口的请求是一样的 添加 async 和 await 就行
import React, { PureComponent } from 'react';
import { counterValue } from '../../services';
class Index extends PureComponent {
state = {
value: 0
};
async componentDidMount() {
await this.fetchData();
}
fetchData = async () => {
const { data, success } = await counterValue();
success && this.setState({ value: data });
};
addition = () => {
this.setState(({ value }) => ({ value: value + 1 }));
};
subtraction = () => {
this.setState(({ value }) => ({ value: value - 1 }));
};
render() {
const { value } = this.state;
return (
<div>
<button onClick={this.addition}>+</button>
<span>{value}</span>
<button onClick={this.subtraction}>-</button>
</div>
)
}
}
export default Index;
import React from 'react';
import { render } from '@testing-library/react';
import axios from 'axios';
import Counter from './index';
// jest 内部对 axios 进行一些特殊处理
jest.mock('axios');
const successResult = {
success: true,
data: 2
};
describe('测试 Counter 组件', () => {
// 在运行此文件中的每个测试之前运行一个函数
beforeEach(() => {
// 自己来mock axios.get 的返回值,避免网络请求
axios.get.mockReturnValue(new Promise((resolve) => resolve(successResult)));
});
test('测试渲染内容', () => {
const { container } = render(<Counter />);
expect(container.querySelector('div')).not.toBe(null);
expect(container.querySelectorAll('button').length).toBe(2);
expect(container.querySelectorAll('span').length).toBe(1);
});
test('测试添加', async () => {
const { container } = await render(<Counter />);
const additionButton = container.querySelectorAll('button')[0];
additionButton.click();
// 这里修改为 3
expect(container.querySelector('span').innerHTML).toBe('3');
});
test('测试减少', async () => {
const { container } = await render(<Counter />);
const subtractionButton = container.querySelectorAll('button')[1];
subtractionButton.click();
// 这里修改为 1
expect(container.querySelector('span').innerHTML).toBe('1');
subtractionButton.click();
// 这里修改为 0
expect(container.querySelector('span').innerHTML).toBe('0');
});
});
image.png
其他更多的方法调用请参考官网
https://testing-library.com/docs/react-testing-library/api#render-result
相关参考
- 如果你需要 redux 测试请参考 https://www.redux.org.cn/docs/recipes/WritingTests.html
- jest官网 https://jestjs.io/docs/en/getting-started
- create-react-app测试用例 https://www.html.cn/create-react-app/docs/running-tests/
- react测试用例 https://zh-hans.reactjs.org/docs/testing.html
- enzyme文档 https://enzymejs.github.io/enzyme/
- react-testing-library文档 https://testing-library.com/docs/react-testing-library/intro
网友评论