Vue 之五 —— 單元測試

單元測試(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 apps 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 斷言: docs.cypress.io/guides/
  • vue組件測試使用官方推薦的test-utils: 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 dontt 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 dont 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

推薦閱讀:

TAG:Vue.js | JavaScript單元測試 | VueCLI3 |