单元测试(unit testing):是指对软件中的最小可测试单元进行检查和验证。代码的终极目标有两个,第一个是实现需求,第二个是提高代码质量和可维护性。单元测试是为了提高代码质量和可维护性,是实现代码的第二个目标的一种方法。对vue组件的测试是希望组件行为符合我们的预期。
本文将从框架选型,环境搭建,使用方式,vue组件测试编写原则四个方面讲述如何在vue项目中落地单元测试。
一、框架选型
cypress
/ vue-test-utils
选择vue-test-utils
是因为它是官方推荐的vue component 单元测试库。
选择cypress
而不是jest
主要是因为:
- 测试环境的一致性: 在cypress上面跑的测试代码是在浏览器环境上的,而非像jest等在node上的。另外由于cypress在浏览器环境上运行,测试dom相关无需各种mock(如node-canvas等)
- 统一测试代码风格、避免技术负担: 本身定位 e2e, 但是支持 unit test。
- 支持CI环境
此外cypress还有很多非常棒的Features,感兴趣的朋友自行参考cypress官方文档。
二、环境搭建
1、安装依赖
npm i cypress @cypress/webpack-preprocessor start-server-and-test nyc babel-plugin-istanbul @vue/test-utils -D
note: 如果是使用vue cli3创建的项目,可以使用
# vue add @vue/cli-plugin-e2e-cypress
# npm i @cypress/webpack-preprocessor start-server-and-test nyc babel-plugin-istanbul @vue/test-utils -D
@cypress/webpack-preprocessor
:引入webpack 预处理器
start-server-and-test
:启动dev-server 监听端口启动成功,再执行测试命令。cypress 需要dev-server启动才能测试。
nyc babel-plugin-istanbul
:覆盖率统计相关
2、添加/修改cypress.json文件
{
"baseUrl": "http://localhost:9001",
"coverageFolder": "coverage",
"integrationFolder": "src",
"testFiles": "**/*.spec.js",
"video": false,
"viewportHeight": 900,
"viewportWidth": 1600,
"chromeWebSecurity": false
}
3、修改package.json配置
"scripts": {
"cy:run": "cypress run",
"cy:open": "cypress open",
"cy:dev": "start-server-and-test start :9001 cy:open",
"coverage": "nyc report -t=coverage",
"test": "rm -rf coverage && start-server-and-test start :9001 cy:run && nyc report -t=coverage"
},
4、修改cypress/plugins/index.js(使用vue add @vue/cli-plugin-e2e-cypress的是tests/e2e//plugins/index.js)
// vue cli3 版本
const webpack = require('@cypress/webpack-preprocessor');
const webpackOptions = require('@vue/cli-service/webpack.config');
webpackOptions.module.rules.forEach(rule => {
if (!Array.isArray(rule.use)) return null;
rule.use.forEach(opt => {
if (opt.loader === 'babel-loader') {
opt.options = {
plugins: ['istanbul']
};
}
});
});
const options = {
webpackOptions,
watchOptions: {},
};
module.exports = (on, config) => {
on('file:preprocessor', webpack(options));
return Object.assign({}, config, {
integrationFolder: 'src',
// screenshotsFolder: 'cypress/screenshots',
// videosFolder: 'cypress/videos',
// supportFile: 'cypress/support/index.js'
})
};
// webpack4 版本
const webpack = require('@cypress/webpack-preprocessor');
const config = require('../../webpack.base');
config.mode = 'development';
config.module.rules[0].use.options = {
plugins: ['istanbul']
};
module.exports = (on) => {
const options = {
// send in the options from your webpack.config.js, so it works the same
// as your app's code
webpackOptions: config,
watchOptions: {},
};
on('file:preprocessor', webpack(options));
};
5、修改cypress/support
// support/index.js
import './commands';
import './istanbul';
在support目录里添加istanbul.js文件
// https://github.com/cypress-io/cypress/issues/346#issuecomment-365220178
// https://github.com/cypress-io/cypress/issues/346#issuecomment-368832585
/* eslint-disable */
const istanbul = require('istanbul-lib-coverage');
const map = istanbul.createCoverageMap({});
const coverageFolder = Cypress.config('coverageFolder');
const coverageFile = `${ coverageFolder }/out-${Date.now()}.json`;
Cypress.on('window:before:unload', e => {
const coverage = e.currentTarget.__coverage__;
if (coverage) {
map.merge(coverage);
}
});
after(() => {
cy.window().then(win => {
const specWin = win.parent.document.querySelector('iframe[id~="Spec:"]').contentWindow;
const unitCoverage = specWin.__coverage__;
const coverage = win.__coverage__;
if (unitCoverage) {
map.merge(unitCoverage);
}
if (coverage) {
map.merge(coverage);
}
cy.writeFile(coverageFile, JSON.stringify(map));
cy.exec('npx nyc report --reporter=html -t=coverage')
cy.exec('npm run coverage')
.then(coverage => {
// output coverage report
const out = coverage.stdout
// 替换bash红色标识符
.replace(/[31;1m/g, '')
.replace(/[0m/g, '')
// 替换粗体标识符
.replace(/[3[23];1m/g, '');
console.log(out);
})
.then(() => {
// output html file link to current test report
const link = Cypress.spec.absolute
.replace(Cypress.spec.relative, `${coverageFolder}/${Cypress.spec.relative}`)
.replace('cypress.spec.', '');
console.log(`check coverage detail: file://${link}.html`);
});
});
});
6、修改package.json (推荐使用git push hooks 里跑test)
"gitHooks": {
"pre-push": "npm run test"
},
"nyc": {
"exclude": [
"**/*.spec.js",
"cypress",
"example"
]
}
note: 如果项目使用了sass来写css,则必须指定node版本为v8.x.x,这个算是cypress的bug。Issuess
# npm install n -g
# sudo n v8.9.0
# npm rebuild node-sass
这样在git push之前会先跑单元测试,通过了才可以push成功。
三、使用方法
- 对于各个 utils 内的方法以及 vue组件,只需在其目录下补充同名的 xxx.spec.js,即可为其添加单元测试用例。
- 断言语法采用 cypress 断言: https://docs.cypress.io/guides/references/assertions.html#Chai
- vue组件测试使用官方推荐的test-utils: https://vue-test-utils.vuejs.org/
- npm 命令测试:
- npm run cy:run (终端测试,前置条件:必须启动本地服务)
- npm run cy:open (GUI 测试,前置条件:必须启动本地服务)
- npm run cy:dev (GUI测试, 自动启动本地服务,成功后打开GUI)
- npm run test (终端测试, 自动启动本地服务,并且统计覆盖率,在终端运行,也是CI运行的测试命令)
四、测试原则
1、明白要测试的是什么
不推荐一味追求行级覆盖率,因为它会导致我们过分关注组件的内部实现细节,而只关注其输入和输出。一个简单的测试用例将会断言一些输入 (用户的交互或 prop 的改变) 提供给某组件之后是否导致预期结果 (渲染结果或触发自定义事件)。
2、测试公共接口
a、如果模板有逻辑,我们应该测试它
// template
<button ref="logOutButton" v-if="loggedIn">Log out</button>
// Button.spec.js
const PropsData = {
loggedIn: true,
};
it('hides the logOut button if user is not logged in', () => {
const wrapper = mount(UserSettingsBar, { PropsData });
const { vm } = wrapper;
expect(vm.$refs.logOutButton).to.exist();
wrapper.setProps({ loggedIn: false });
expect(vm.$refs.logOutButton).not.to.exist();
});
原则:Props in Rendered Output
b、什么超出了我们组件的范围
- 实现细节,过分关注组件的内部实现细节,从而导致琐碎的测试。
- 测试框架本身, 这是vue应该去做的事情。
1、<p> {{ myProp }} </p>
expect(p.text()).to.be(/ prop value /);
2、prop 校验
c、权衡
Integration Test
// Count.spec.js
it('should display the updated count after button is clicked', () => {
const wrapper = mount(Count, {
count: 0
});
const ButtonInstance = wrapper.find(Button);
const buttonEl = ButtonInstance.find('button')[0]; // find button click
buttonE1.trigger('click');
const CounterDisplayInstance = wrapper.find(CounterDisplay);
const displayE1 = CounterDisplayInstance.find('.count-display')[0];
expect(displayE1.text()).to.equal('1'); // find display, assert render
});
Shallow Test
// Count.spec.js
it('should pass the "count" prop to CounterDisplay', () => {
const counterWrapper = shallow(Counter, {
count: 10
});
const counterDisplayWrapper = counterWrapper.find(CounterDisplay);
// we dont't care how this will be rendered
expect(counterDisplayWrapper.propsData().count).to.equal(10);
});
it('should update the "count" prop by 1 on Button "increment" event', () => {
const counterWrapper = shallow(Counter, {
count: 10
});
const buttonWrapper = counterWrapper.find(Button);
// we don't care how this was triggered
buttonWrapper.vm.$emit('increment');
expect(CounterDisplay.propsData().count).to.equal(11);
});
参考:
cypress-vue-unit-test
Vue Test Utils
Component Tests with Vue.js