1、 概述
1、 為什么需要自動化測試?
項(xiàng)目會從快速迭代走向以維護(hù)為主的狀態(tài),因此引入自動化測試能有效減少人工維成本 。
自動化的收益 = 迭代次數(shù) * 全手動執(zhí)行成本 - 首次自動化成本 - 維護(hù)次數(shù) * 維護(hù)成本 |
對于自動化測試,相對于發(fā)現(xiàn)未知問題,更傾向于避免可能的問題。
2、 分類
(1) 單元測試
單元測試(unit testing),是指對軟件中的最小可測試單元進(jìn)行檢查和驗(yàn)證,通常是針對函數(shù)、模塊、對象進(jìn)行測試,但在前端應(yīng)用中組件也是被測單元,對于代碼中多個組件共用的工具類庫、多個組件共用的子組件應(yīng)盡可能提高覆蓋率。
特點(diǎn):
- 單元測試執(zhí)行速度很快;
- 應(yīng)該避免依賴性問題,如不存取數(shù)據(jù)庫,不訪問網(wǎng)絡(luò)等,而是使用工具虛擬出運(yùn)行環(huán)境;
- 由于單元測試是獨(dú)立的,因此無法保證多個單元一起運(yùn)行時的正確性。
意義:
- 通過用例確保模塊的功能,不至于在迭代過程中產(chǎn)生 bug ;
- 保證代碼重構(gòu)的安全性,測試用例能給你多變的代碼結(jié)構(gòu)一個定心丸;
- 如果模塊邏輯越來越復(fù)雜,通過單測用例,也能比較快地了解模塊的功能 ;
- 提高代碼質(zhì)量,使得代碼設(shè)計(jì)的與外部模塊更加解耦。
(2) UI測試
TODO文章來源地址http://www.zghlxwxcb.cn/news/detail-717832.html
(3) E2E測試
TODO
3、 測試思想
TDD:Test-Driven Development(測試驅(qū)動開發(fā))
TDD 要求在編寫某個功能的代碼之前先編寫測試代碼,然后只編寫使測試通過的功代碼,通過測試來推動整個開發(fā)的進(jìn)行。
BDD:Behavior-Driven Development(行為驅(qū)動開發(fā))
BDD 可以讓項(xiàng)目成員(甚至是不懂編程的)使用自然語言來描述系統(tǒng)功能和業(yè)務(wù)輯,從而根據(jù)這些描述步驟進(jìn)行系統(tǒng)自動化的測試。
2、 技術(shù)選型
1、 單元測試
框架對比:
框架 | 斷言 | 仿真 | 快照 | 異步測試 | 覆蓋率 |
---|---|---|---|---|---|
Mocha | 默認(rèn)不支持 | 默認(rèn)不支持 | 默認(rèn)不支持 | 友好 | 不支持 |
Ava | 默認(rèn)支持 | 不支持 | 默認(rèn)支持 | 友好 | 不支持 |
Jasmine | 默認(rèn)支持 | 默認(rèn)支持 | 默認(rèn)支持 | 不友好 | |
Jest | 默認(rèn)支持 | 默認(rèn)支持 | 默認(rèn)支持 | 友好 | 默認(rèn)支持 |
Karma | 不支持 | 不支持 | 不支持 | 不支持 |
經(jīng)過對比,主要在Jest和Mocha間進(jìn)行選擇,同樣Vue Test Utils ( Vue.js 官方的元測試實(shí)用工具庫)中也主要介紹了該兩種框架的使用方式。
Jest默認(rèn)支持所需多種場景,可通過較少配置滿足所需功能,開箱即用,同時我們通希望與Jenkins完成配合,如設(shè)置某項(xiàng)指標(biāo)覆蓋率低于80%則不進(jìn)行build,不通過Jenkins校驗(yàn),Jest可以簡單配置coverageThreshold進(jìn)行實(shí)現(xiàn),除此以外也可以單獨(dú)為某個模塊配置報(bào)錯閾值,提供更靈活的覆蓋率選擇。
// jest.config.js
module.exports = {
coverageThreshold: {
// 覆蓋結(jié)果的最低閾值設(shè)置,如果未達(dá)到閾值,jest將返回失敗。
global: {
branches: 60,
functions: 80,
lines: 80,
statements: 80,
},
}
}
綜上所述,前端單元測試采用Jest框架+ Vue Test Utils完成單元測試,并對工具未覆蓋的常用方法進(jìn)行封裝。
使用方式:
- 斷言:所謂斷言,就是判斷源碼的實(shí)際執(zhí)行結(jié)果與預(yù)期結(jié)果是否一致,如果不一致就拋出一個錯誤,通常斷言庫為expect斷言風(fēng)格(BDD),更接近自然語言;
- 仿真:即通常所說的mock功能,當(dāng)需要測試的單元需要外部模塊時,同時這些模塊具有不可控、實(shí)現(xiàn)成本高等原因時,此時采用mock,例如模擬http請求;
- 快照:快照測試通常是對UI組件渲染結(jié)果的測試,而在jest中,快照測試是保存渲染組件的標(biāo)記,從而達(dá)到快照文件體積小,測試速度快的目的;
- 異步測試:通常異步測試進(jìn)行http請求的異步獲取模擬,支持promise,async/await等語法,能夠簡單進(jìn)行異步模擬;
-
覆蓋率:覆蓋率通常通過以下指標(biāo)進(jìn)行統(tǒng)計(jì):
- %stmts是語句覆蓋率(statement coverage):是不是每個語句都執(zhí)行了?
- %Branch分支覆蓋率(branch coverage):是不是每個if代碼塊都執(zhí)行了?
- %Funcs函數(shù)覆蓋率(function coverage):是不是每個函數(shù)都調(diào)用了?
- %Lines行覆蓋率(line coverage):是不是每一行都執(zhí)行了?
我們至少需要測試框架(運(yùn)行測試的工具),斷言庫來保證單元測試的正常執(zhí)行。在業(yè)務(wù)場景中,Api請求等異步場景也希望框架擁有異步測試能力,同時希望框架支持生成覆蓋率報(bào)告。
2、 UI測試
TODO
3、 E2E測試
TODO
3、 單元測試
1、 依賴安裝
vue add @vue/cli-plugin-unit-jest
通過該命令將自動安裝Jest和Vue Test Utils等所需工具
依賴安裝完成后我們在package.json文件應(yīng)該能看到以下依賴:
項(xiàng)目自動生成如下文件:
tests目錄是自動化測試的工作區(qū),可mock方法、mock請求、預(yù)置配置、加入工具方法、編寫單元測試等。
jest.config.js文件用于配置jest的測試環(huán)境、es6語法轉(zhuǎn)換、需要檢測的文件類型、css預(yù)處理、覆蓋率報(bào)告等。
2、 Jest配置
// jest.config.js
module.exports = {
preset: "@vue/cli-plugin-unit-jest",
verbose: true, // 多于一個測試文件運(yùn)行時展示每個測試用例測試通過情況
bail: true, // 參數(shù)指定只要有一個測試用例沒有通過,就停止執(zhí)行后面的測試用例
testEnvironment: 'jsdom', // 測試環(huán)境,jsdom可以在Node虛擬瀏覽器環(huán)境運(yùn)行測試
moduleFileExtensions: [ // 需要檢測測的文件類型
'js',
'jsx',
'json',
// tell Jest to handle *.vue files
'vue'
],
transform: { // 預(yù)處理器配置,匹配的文件要經(jīng)過轉(zhuǎn)譯才能被識別,否則會報(bào)錯
'.+\\.(css|styl|less|sass|scss|jpg|jpeg|png|svg|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|avif)$':
require.resolve('jest-transform-stub'),
'^.+\\.jsx?$': require.resolve('babel-jest')
},
transformIgnorePatterns: ['/node_modules/'], // 轉(zhuǎn)譯時忽略 node_modules
moduleNameMapper: { // 從正則表達(dá)式到模塊名稱的映射,和webpack的alisa類似
"\\.(css|less|scss|sass)$": "<rootDir>/tests/unit/StyleMock.js",
},
snapshotSerializers: [ // Jest在快照測試中使用的快照序列化程序模塊的路徑列表
'jest-serializer-vue'
],
testMatch: [ // Jest用于檢測測試的文件,可以用正則去匹配
'**/tests/unit/**/*.spec.[jt]s?(x)',
'**/__tests__/*.[jt]s?(x)'
],
collectCoverage: true, // 覆蓋率報(bào)告,運(yùn)行測試命令后終端會展示報(bào)告結(jié)果
collectCoverageFrom: [ // 需要進(jìn)行收集覆蓋率的文件,會依次進(jìn)行執(zhí)行符合的文件
'src/views/**/*.{js,vue}',
'!**/node_modules * '
],
coverageDirectory: "<rootDir>/tests/unit/coverage", // Jest輸出覆蓋信息文件的目錄,運(yùn)行測試命令會自動生成如下路徑的coverage文件
coverageThreshold: { // 覆蓋結(jié)果的最低閾值設(shè)置,如果未達(dá)到閾值,jest將返回失敗
global: {
branches: 60,
functions: 80,
lines: 80,
statements: 80,
},
"src/views/materialManage/materialList/index.vue": {
branches: 100,
functions: 100,
lines: 100,
statements: 100,
},
},
setupFiles: ["<rootDir>/tests/unit/setup/main.setup.js"] // 環(huán)境預(yù)置配置文件入口
};
-
preset(@vue/cli-plugin-unit-jest):提供了jest默認(rèn)配置,可通過路徑node_modules/@vue/cli-plugin-unit-jest/presets/default/jest-preset.js找到該默認(rèn)配置;
-
verbose:多于一個測試文件運(yùn)行時展示每個測試用例測試通過情況,默認(rèn)多于一個測試文件時不展示;
-
bail:默認(rèn)Jest會運(yùn)行所有測試用例并將全部錯誤輸出至控制臺,bail可設(shè)置當(dāng)n個用例不通過后停止測試,當(dāng)設(shè)置為true時等同于1,在后續(xù)與Jenkins配合時可將其配置為true,減少不必要的資源消耗,默認(rèn)值為0;
-
testEnvironment(jsdom):jsdom可以讓js在node環(huán)境運(yùn)行,是自動化測試必要條件;
-
moduleFileExtensions:jest需要檢測測的文件類型;
-
transform:預(yù)處理器配置,匹配的文件要經(jīng)過轉(zhuǎn)譯才能被識別,否則會報(bào)錯;
-
transformIgnorePatterns:匹配所有源文件路徑的regexp模式字符串?dāng)?shù)組,匹配的文件將跳過轉(zhuǎn)換;
-
moduleNameMapper:從正則表達(dá)式到模塊名稱的映射,支持源代碼中相同的@別名,與vue.config.js中chainWebpack的alias相對應(yīng);
-
snapshotSerializers:Jest在快照測試中使用的快照序列化程序模塊的路徑列表;
-
testMatch:當(dāng)只需要進(jìn)行某個目錄下的單元測試腳本執(zhí)行時可以進(jìn)行該配置,例如示例中僅執(zhí)行unit下的測試腳本,默認(rèn)直接注釋該行即可;
-
collectCoverage:是否生成覆蓋率報(bào)告,將會為每個測試范圍內(nèi)的文件收集并統(tǒng)計(jì)覆蓋率,生成html可視的測試報(bào)告,但會顯著降低單元測試運(yùn)行效率,通常設(shè)為默認(rèn)值false;
- 使用瀏覽器打開tests/unit/coverage/lcov-report路徑下的index.html文件即可瀏覽各個被測試的文件的詳細(xì)覆蓋信息。
- 使用瀏覽器打開tests/unit/coverage/lcov-report路徑下的index.html文件即可瀏覽各個被測試的文件的詳細(xì)覆蓋信息。
-
collectCoverageFrom:設(shè)置收集覆蓋率的文件范圍;
- 通常業(yè)務(wù)代碼編寫在src/views中,因此此處設(shè)置src/views下的js,vue文件;
- 同時src/components中部分組件不希望在覆蓋率中被捕捉,因此可單獨(dú)配置希望進(jìn)行收集的目錄;
- 可以通過在前方配置!設(shè)置某目錄下不進(jìn)行覆蓋率收集,例如上方node_modules。
-
coverageDirectory:覆蓋率報(bào)告生成位置,運(yùn)行npm run test:unit命令跑單測即可生成,配合.gitignore不將覆蓋率報(bào)告提交至git倉庫;
-
coverageThreshold:支持設(shè)置statements、branches、functions、lines四種指標(biāo)的最低覆蓋率,當(dāng)未符合設(shè)置閾值時,則判定單元測試失敗,后續(xù)通過設(shè)置不同業(yè)務(wù)的覆蓋率閾值來完成與Jenkins的對接;
- 支持為某個路徑下的文件單獨(dú)進(jìn)行閾值設(shè)置
- 當(dāng)設(shè)置負(fù)數(shù)-n時,則為未覆蓋率不允許超過n%。
-
setupFiles:在運(yùn)行單元測試前,先運(yùn)行的文件,用于進(jìn)行預(yù)制配置的設(shè)置,例如接口mock、插件配置、封裝方法等;
3、 目錄結(jié)構(gòu)
實(shí)際開發(fā)過程中,我們應(yīng)當(dāng)具備較為完善的自動化測試目錄結(jié)構(gòu):
(1) .eslintrc.js
module.exports = {
env: {
jest: true,
},
globals: {
utils: "writalbe",
$: "writalbe",
moment: "writalbe",
},
};
配置在unit目錄下的eslint規(guī)則。
- 聲明環(huán)境為jest以此保證使用jest api時不會觸發(fā)Eslint報(bào)錯;
- 由于上方將utils注冊到global中,后續(xù)使用直接通過utils.[functionName]調(diào)用,此處將utils設(shè)置為全局變量,實(shí)現(xiàn)在測試腳本中直接使用utils不會出現(xiàn)Eslint報(bào)錯,$、moment同理。
(2) setup
main.setup.js
import "./api"; // api Mock
import './utils' // 工具方法
import './plugins' // 插件聲明
按照順序進(jìn)行引入,優(yōu)先聲明方法mock/插件聲明,后引入預(yù)置配置和工具方法。
plugins目錄
// index.js
import "./global";
插件聲明入口文件,統(tǒng)一引入,下方舉例。
// global.js
import Vue from 'vue'
import ElementUI from "element-ui";
import "element-ui/lib/theme-chalk/index.css";
import { parseTime, resetForm } from "@/utils/general";
import { hasPermi } from "@/directives/hasPermi";
import Pagination from "@/components/Pagination";
import ebDialog from "@/components/eb-components/EB-dialog";
Vue.prototype.msgSuccess = function (msg) {
this.$message({ showClose: true, message: msg, type: "success" });
};
Vue.prototype.msgWarning = function (msg) {
this.$message({ showClose: true, message: msg, type: "warning" });
};
Vue.prototype.msgError = function (msg) {
this.$message({ showClose: true, message: msg, type: "error" });
};
Vue.use(ElementUI);
Vue.prototype.parseTime = parseTime;
Vue.prototype.resetForm = resetForm;
Vue.directive("hasPermi", { hasPermi });
Vue.component("Pagination", Pagination);
Vue.component("ebDialog", ebDialog);
通過上述方式,將所需插件進(jìn)行注冊:
- jest在執(zhí)行測試腳本時,不會像正常執(zhí)行過程中優(yōu)先執(zhí)行main.js,例如在測試腳本中渲染materialList/index.vue,此時只會執(zhí)行該文件的生命周期,因此需要通過該種方式對公用插件進(jìn)行全局注冊,保證測試腳本的正常執(zhí)行;
- 同樣,后續(xù)在引入其余插件時,應(yīng)在該文件同級目錄下創(chuàng)建相應(yīng)以插件名稱命名的文件,并在index.js中引入。
utils目錄
// index.js
import { timeout, request, response, mockApi } from "./api"; // api 封裝方法
import { // 工具類封裝方法
getTablesHeader, // 獲取表頭
getTablesData, // 獲取表格數(shù)據(jù)
getTablesAction, // 獲取表格操作列
getButton, // 獲取按鈕
getTableButton, // 獲取表格按鈕
getModalTitles, // 獲取彈窗標(biāo)題
getModalCloses, // 獲取彈窗關(guān)閉按鈕
getNotificationsContent, // 獲取Notification提示
removeNotifications, // 移除Notification提示
getConfirmsContent, // 獲取Confirm氣泡確認(rèn)框內(nèi)容
getConfirmButton, // 獲取Confirm氣泡確認(rèn)框按鈕
getMessageContent, // 獲取Message信息內(nèi)容
getFormItems, // 獲取表單項(xiàng)
getFormErrors, // 獲取表單校驗(yàn)失敗信息
getSelect, // 獲取下拉框
// 以下未實(shí)現(xiàn),需要使用請自行封裝
getActiveTabs,
getTabButton,
getCheckboxs,
getIcon,
getTableSelections,
getBreadcrumbButton,
getDropdownOptions,
getDropdownButton,
getSelectOption,
getAllowClear,
getModalClose,
} from "./element-ui";
global.utils = {
// api
timeout,
request,
response,
mockApi,
// element-ui
getTablesHeader,
getTablesData,
getTablesAction,
getButton,
getTableButton,
getModalTitles,
getModalCloses,
getNotificationsContent,
removeNotifications,
getConfirmsContent,
getConfirmButton,
getMessageContent,
getFormItems,
getFormErrors,
getSelect,
// 以下未實(shí)現(xiàn),需要使用請自行封裝
getActiveTabs,
getTabButton,
getCheckboxs,
getIcon,
getTableSelections,
getBreadcrumbButton,
getDropdownOptions,
getDropdownButton,
getSelectOption,
getAllowClear,
getModalClose,
};
工具方法注冊入口文件,統(tǒng)一引入常用的封裝方法,并將其注冊置global.utils中,在后續(xù)測試腳本中無需import,直接通過utils.${functionName}進(jìn)行調(diào)用。
// api.js
// 延時器
export function timeout (time) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve()
}, time)
})
}
// 模擬接口請求
export function request () {
return jest.fn(
(params) =>
utils.response({})
)
}
// 模擬接口響應(yīng)
export function response (data) {
return new Promise((resolve, reject) => {
resolve(data)
})
}
// 自定義mock-修改單一api響應(yīng)數(shù)據(jù)
export function mockApi (mock, api, data) {
mock[api].mockImplementation(() => utils.response(data))
}
在單元測試中,需要模擬接口響應(yīng)在多種情況下的不同場景,尤其是在真實(shí)開發(fā)環(huán)境中不好模擬的場景,例如存在時間極短的中間狀態(tài)等。
- 在原先的Jest調(diào)研中,選擇的mock對象為axios方法,而通過mock axios時,無法做到靈活的多組mock數(shù)據(jù)的使用;在本次調(diào)研中選擇mock各個api,并通過mockImplementation實(shí)現(xiàn)在describe以及it中的數(shù)據(jù)更改,由此實(shí)現(xiàn)靈活的多組mock實(shí)現(xiàn),來覆蓋更多場景。
api目錄
// index.js
jest.mock("@/api/materialList/materialList", () =>
require("@/../tests/unit/setup/api/materialList.mock"),
);
jest.mock("@/api/categoryManage/categoryManage", () =>
require("@/../tests/unit/setup/api/categoryManage.mock"),
);
通過jest.mock模擬api中的相應(yīng)方法,達(dá)到全局api初始化,與views/api中的文件對應(yīng),在api目錄下創(chuàng)建對應(yīng)文件名的.mock.js文件。
export const getMaterialList = utils.request();
export const getjudgeCategory = utils.request();
export const addMaterial = utils.request();
export const getMaterialDetail = utils.request();
export const updateMaterial = utils.request();
在對應(yīng)文件的.mock.js文件中,通過上述方式聲明業(yè)務(wù)代碼中的各api函數(shù),上述含義為將所聲明接口返回值初始化為空對象{},使用jest.fn進(jìn)行接口模擬,通過utils.response返回promise,模擬接口響應(yīng)。
- 文件命名與src/api中相應(yīng)文件相同,即如src/api/materialList.js中的api則此處應(yīng)創(chuàng)建materialList.mock.js文件。
(3) specs
specs中的目錄結(jié)構(gòu)應(yīng)與項(xiàng)目所測試目錄保持一致,例如views/materialManage/materialList/index.vue的測試腳本在specs中應(yīng)在views/materialManage/materialList目錄下,以此保持單元測試代碼的可讀/可維護(hù)性,下方以materialList目錄下的index.vue文件舉例(此處僅展示基本流程,具體用例編寫參見后續(xù)樣例)。
// materialList.spec.js
import { mount } from "@vue/test-utils";
import materialList from "@/views/materialManage/materialList/index.vue";
import mockData from "./mockData";
const materialListApi = require("@/../tests/unit/setup/api/materialList.mock");
const categoryManageApi = require("@/../tests/unit/setup/api/categoryManage.mock");
utils.mockApi(
materialListApi,
"getMaterialList",
mockData.success.getMaterialList,
);
describe("素材列表頁", () => {
const wrapper = mount(materialList);
const _this = wrapper.vm;
it("素材列表頁-查詢失敗", async () => {
utils.mockApi(
materialListApi,
"getMaterialList",
mockData.failure.getMaterialList,
);
_this.pageList = [];
_this.total = 0;
_this.loading = false;
await utils.getButton(wrapper, "搜索").trigger("click");
expect(_this.pageList).toEqual([]);
expect(_this.total).toBe(0);
expect(_this.loading).toBe(true);
});
it("素材列表頁-查詢成功", async () => {
utils.mockApi(
materialListApi,
"getMaterialList",
mockData.success.getMaterialList,
);
_this.pageList = [];
_this.total = 0;
_this.loading = false;
await utils.getButton(wrapper, "搜索").trigger("click");
let expectData = mockData.success.getMaterialList.data;
expect(_this.pageList).toEqual(expectData.list);
expect(_this.total).toBe(expectData.total);
expect(_this.loading).toBe(false);
});
});
上方示例中通過jest.mock模擬api中的materialList文件的相應(yīng)方法,下方通過utils.mockApi對getMaterialList進(jìn)行重新處理,實(shí)現(xiàn)靈活的mock數(shù)據(jù)修改。
// mockData.js
const mockData = {
success: {
getMaterialList: {
code: 200,
data: {
total: 83,
list: [
{
md5File: "969e0a368a3a3ec423fccc39433c7427",
materialUrl:
"https://rcs.telinovo.com/material/96/9e0a368a3a3ec423fccc39433c7427.mp4",
showUrl: null,
dir: "96",
realName: "9e0a368a3a3ec423fccc39433c7427.mp4",
createTime: "2022-12-23T06:19:31.000+0000",
categoryId: 2,
materialName: "測試視頻",
phone: null,
fileType: 2,
categoryName: "默認(rèn)分類/默認(rèn)分類",
},
{
md5File: "ae543e4e6d8706faee63ed3be07f1b7c",
materialUrl:
"https://rcs.telinovo.com/material/ae/543e4e6d8706faee63ed3be07f1b7c.png",
showUrl: null,
dir: "ae",
realName: "543e4e6d8706faee63ed3be07f1b7c.png",
createTime: "2022-12-22T08:58:27.000+0000",
categoryId: 55,
materialName: "關(guān)注攻略",
phone: null,
fileType: 1,
categoryName: "活動圖片/封面圖片",
},
],
},
message: "操作成功",
},
},
failure: {
getMaterialList: {
code: 500,
data: null,
message: "操作失敗",
},
},
};
export default mockData;
在mockData中分別設(shè)置success,failure時的api mock數(shù)據(jù),該種方式利于后續(xù)在斷言中進(jìn)行響應(yīng)結(jié)果判斷。
(4) StyleMock.js
module.exports = {}
上述moduleNameMapper提到Jest運(yùn)行無法識別import .css/.less等后綴,將其映射到該js文件,此處直接exports空對象保證測試腳本正常執(zhí)行。
- 單元測試本身不關(guān)注樣式,但關(guān)注dom結(jié)構(gòu)。
4、 Api
(1) vue-test-utils
vue-test-utils主要負(fù)責(zé)節(jié)點(diǎn)獲取,編寫測試邏輯。下面列舉幾個常用的Api,以及介紹一下wrapper對象。
Api
-
mount
創(chuàng)建一個包含被掛載和渲染的 Vue 組件的 Wrapper。
import { mount } from '@vue/test-utils'
import Foo from './Foo.vue'
describe('Foo', () => {
it('renders a div', () => {
const wrapper = mount(Foo)
expect(wrapper.contains('div')).toBe(true)
})
})
-
shallowMount
和mount一樣,創(chuàng)建一個包含被掛載和渲染的 Vue 組件的 Wrapper,與shallowMount區(qū)別:- mount會渲染整個組件樹而shallowMount會對子組件存根;
- shallowMount可以確保你對一個組件進(jìn)行獨(dú)立測試,有助于避免測試中因子組件的渲染輸出而混亂結(jié)果。
import { shallowMount } from '@vue/test-utils'
import Foo from './Foo.vue'
describe('Foo', () => {
it('renders a div', () => {
const wrapper = shallowMount(Foo)
expect(wrapper.contains('div')).toBe(true)
})
})
Wrapper
Wrapper 是一個對象,該對象包含了一個掛載的組件或 vnode,以及測試該組件或 vnode 的方法。
下面介紹一些它的常用方法。
-
attributes
返回 Wrapper DOM 節(jié)點(diǎn)的特性對象。如果提供了 key,則返回這個 key 對應(yīng)的值。
import { mount } from '@vue/test-utils'
import Foo from './Foo.vue'
const wrapper = mount(Foo)
expect(wrapper.attributes().id).toBe('foo')
expect(wrapper.attributes('id')).toBe('foo')
-
classes
返回 Wrapper DOM 節(jié)點(diǎn)的 class。
返回 class 名稱的數(shù)組?;蛟谔峁?class 名的時候返回一個布爾值。
import { mount } from '@vue/test-utils'
import Foo from './Foo.vue'
const wrapper = mount(Foo)
expect(wrapper.classes()).toContain('bar')
expect(wrapper.classes('bar')).toBe(true)
-
contains
判斷 Wrapper 是否包含了一個匹配選擇器的元素或組件。
import { mount } from '@vue/test-utils'
import Foo from './Foo.vue'
import Bar from './Bar.vue'
const wrapper = mount(Foo)
expect(wrapper.contains('p')).toBe(true)
expect(wrapper.contains(Bar)).toBe(true)
-
find
返回匹配選擇器的第一個 DOM 節(jié)點(diǎn)或 Vue 組件的 Wrapper。
可以使用任何有效的 DOM 選擇器 (使用 querySelector 語法)。
import { mount } from '@vue/test-utils'
import Foo from './Foo.vue'
import Bar from './Bar.vue'
const wrapper = mount(Foo)
const div = wrapper.find('div')
expect(div.exists()).toBe(true)
const byId = wrapper.find('#bar')
expect(byId.element.id).toBe('bar')
-
findAll
返回一個 WrapperArray。
可以使用任何有效的選擇器。
import { mount } from '@vue/test-utils'
import Foo from './Foo.vue'
import Bar from './Bar.vue'
const wrapper = mount(Foo)
const div = wrapper.findAll('div').at(0)
expect(div.is('div')).toBe(true)
const bar = wrapper.findAll(Bar).at(0) // 已廢棄的用法
expect(bar.is(Bar)).toBe(true)
-
findComponent
返回第一個匹配的 Vue 組件的 Wrapper。
import { mount } from '@vue/test-utils'
import Foo from './Foo.vue'
import Bar from './Bar.vue'
const wrapper = mount(Foo)
const bar = wrapper.findComponent(Bar) // => 通過組件實(shí)例找到 Bar
expect(bar.exists()).toBe(true)
const barByName = wrapper.findComponent({ name: 'bar' }) // => 通過 `name` 找到 Bar
expect(barByName.exists()).toBe(true)
const barRef = wrapper.findComponent({ ref: 'bar' }) // => 通過 `ref` 找到 Bar
expect(barRef.exists()).toBe(true)
-
findAllComponents
為所有匹配的 Vue 組件返回一個 WrapperArray。
import { mount } from '@vue/test-utils'
import Foo from './Foo.vue'
import Bar from './Bar.vue'
const wrapper = mount(Foo)
const bar = wrapper.findAllComponents(Bar).at(0)
expect(bar.exists()).toBeTruthy()
const bars = wrapper.findAllComponents(Bar)
expect(bars).toHaveLength(1)
-
html
返回 Wrapper DOM 節(jié)點(diǎn)的 HTML 字符串。
import { mount } from '@vue/test-utils'
import Foo from './Foo.vue'
const wrapper = mount(Foo)
expect(wrapper.html()).toBe('<div><p>Foo</p></div>')
-
text
返回 Wrapper 的文本內(nèi)容。
import { mount } from '@vue/test-utils'
import Foo from './Foo.vue'
const wrapper = mount(Foo)
expect(wrapper.text()).toBe('bar')
-
is
斷言 Wrapper DOM 節(jié)點(diǎn)或 vm 匹配選擇器。
import { mount } from '@vue/test-utils'
import Foo from './Foo.vue'
const wrapper = mount(Foo)
expect(wrapper.is('div')).toBe(true)
-
setData
設(shè)置 Wrapper vm 的屬性。
setData 通過遞歸調(diào)用 Vue.set 生效。
import { mount } from '@vue/test-utils'
import Foo from './Foo.vue'
test('setData demo', async () => {
const wrapper = mount(Foo)
await wrapper.setData({ foo: 'bar' })
expect(wrapper.vm.foo).toBe('bar')
})
-
trigger
在該 Wrapper DOM 節(jié)點(diǎn)上異步觸發(fā)一個事件。
import { mount } from '@vue/test-utils'
import Foo from './Foo'
test('trigger demo', async () => {
const wrapper = mount(Foo)
await wrapper.trigger('click')
await wrapper.trigger('click', {
button: 0
})
await wrapper.trigger('click', {
ctrlKey: true // 用于測試 @click.ctrl 處理函數(shù)
})
})
WrapperArray
一個 WrapperArray 是一個包含 Wrapper 數(shù)組以及 Wrapper 的測試方法等對象。
下面介紹一些它的常用方法。
-
at
返回第 index 個傳入的 Wrapper 。數(shù)字從 0 開始計(jì)數(shù) (比如第一個項(xiàng)目的索引值是 0)。如果 index 是負(fù)數(shù),則從最后一個元素往回計(jì)數(shù) (比如最后一個項(xiàng)目的索引值是 -1)。
import { shallowMount } from '@vue/test-utils'
import Foo from './Foo.vue'
const wrapper = shallowMount(Foo)
const divArray = wrapper.findAll('div')
const secondDiv = divArray.at(1)
expect(secondDiv.is('div')).toBe(true)
const lastDiv = divArray.at(-1)
expect(lastDiv.is('div')).toBe(true)
-
filter
用一個針對 Wrapper 的斷言函數(shù)過濾 WrapperArray。
該方法的行為和 Array.prototype.filter 相同。
import { shallowMount } from '@vue/test-utils'
import Foo from './Foo.vue'
const wrapper = shallowMount(Foo)
const filteredDivArray = wrapper
.findAll('div')
.filter(w => !w.hasClass('filtered'))
-
setData
為 WrapperArray 的每個 Wrapper vm 都設(shè)置數(shù)據(jù)。
import { mount } from '@vue/test-utils'
import Foo from './Foo.vue'
import Bar from './Bar.vue'
test('setData demo', async () => {
const wrapper = mount(Foo)
const barArray = wrapper.findAll(Bar)
await barArray.setData({ foo: 'bar' })
expect(barArray.at(0).vm.foo).toBe('bar')
})
-
trigger
為 WrapperArray 的每個 Wrapper DOM 節(jié)點(diǎn)都觸發(fā)一個事件。
import { mount } from '@vue/test-utils'
import Foo from './Foo.vue'
test('trigger demo', async () => {
const wrapper = mount(Foo)
const divArray = wrapper.findAll('div')
await divArray.trigger('click')
})
更多信息詳見Vue Test Utils 中文官方文檔。
(2) Jest
Jest主要負(fù)責(zé)對測試結(jié)果進(jìn)行斷言。下面例舉一些常用斷言函數(shù)。
- except(data).toBe(value):判斷expect內(nèi)容是否與value相同;
- except(data).toBeTruthy():除了false , 0 , ‘’ , null , undefined , NaN都將通過;
- except(data).toBeFalsy():與上述相反;
- except(data).toEqual(value):比較Object/Array是否相同。
更多信息詳見Jest中文文檔。
(3) 封裝工具
以下例舉部分封裝的工具方法。
- 獲取表格數(shù)據(jù)
/**
* 獲取全部表格-數(shù)據(jù)
* @param {wrapper}
* @param {scrollable}
* @returns {Object}
*/
export function getTablesData(wrapper) {
let result = {};
let tables = wrapper.findAll(".el-table");
for (let tableIndex = 0; tableIndex < tables.length; tableIndex++) {
result["table-" + tableIndex] = {};
let headers;
headers = tables.at(tableIndex).find(".el-table__header").findAll("th");
let titles = [];
let operation = false;
for (let headerIndex = 0; headerIndex < headers.length; headerIndex++) {
let title = headers.at(headerIndex).find(".cell").text();
titles.push(title);
if (
headerIndex === headers.length - 1 &&
headers.at(headerIndex).find(".cell").text().includes("操作")
) {
operation = true;
}
}
let rows = tables
.at(tableIndex)
.find(".el-table__body")
.findAll(".el-table__row");
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
result["table-" + tableIndex]["row-" + rowIndex] = {};
let tds = rows.at(rowIndex).findAll("td");
for (let tdIndex = 0; tdIndex < tds.length; tdIndex++) {
if (tdIndex < tds.length - 1 || !operation) {
let td = tds.at(tdIndex);
// 由于圖片使用的el-image,它會異步渲染真實(shí)圖片,優(yōu)先渲染el-image__placeholder,所以同步代碼中是拿不到真實(shí)圖片的
// 解決方案:使用el-image時,添加placeholder插槽,自定義傳入圖片資源地址
if (td.findAll("img").length) {
result["table-" + tableIndex]["row-" + rowIndex][titles[tdIndex]] =
td.find("img").attributes("src");
}
if (td.findAll("video").length) {
result["table-" + tableIndex]["row-" + rowIndex][titles[tdIndex]] =
td.find("video").attributes("src");
}
if (!td.findAll("img").length && !td.findAll("video").length) {
result["table-" + tableIndex]["row-" + rowIndex][titles[tdIndex]] =
td.text();
}
}
}
}
}
return result;
}
- 獲取表單項(xiàng)
/**
* 獲取全部表單項(xiàng)信息
* @param {wrapper}
* @returns {Array}
*/
export async function getFormItems(wrapper) {
await wrapper.vm.$nextTick();
let res = [];
// 后面的元素會覆蓋前面的
let types = [
"el-radio",
"el-radio-group",
"el-checkbox",
"el-checkbox-group",
"el-input",
"el-input-number",
"el-select",
"el-cascader",
"el-switch",
"el-slider",
"el-date-editor--time-select",
"el-date-editor--time",
"el-date-editor--timerange",
"el-date-editor--date",
"el-date-editor--dates",
"el-date-editor--week",
"el-date-editor--month",
"el-date-editor--months",
"el-date-editor--year",
"el-date-editor--years",
"el-date-editor--daterange",
"el-date-editor--monthrange",
"el-date-editor--datetime",
"el-date-editor--datetimerange",
"el-upload",
"el-rate",
"el-color-picker",
"el-transfer",
];
let formItems = $(
$("body").find(".el-form")[$("body").find(".el-form").length - 1],
).find(".el-form-item");
if (!formItems.length) {
formItems = $(
$(wrapper.html()).find(".el-form")[
$(wrapper.html()).find(".el-form").length - 1
],
).find(".el-form-item");
}
Array.from(formItems).forEach(formItem => {
let required = false;
let classArr = $(formItem).attr("class").split(" ");
if (classArr.filter(item => item.includes("required")).length) {
required = true;
}
let label = $(formItem).find(".el-form-item__label").text();
let disabled = $(formItem).html().includes("disabled");
let type = "";
let htmlContent = $(formItem).find(".el-form-item__content").html();
types.forEach(item => {
if (htmlContent.includes(item)) {
if (item === "el-date-editor--time-select") {
type = "el-time-select";
} else if (
item === "el-date-editor--time" ||
item === "el-date-editor--timerange"
) {
type = "el-time-picker";
} else if (
item === "el-date-editor--date" ||
item === "el-date-editor--dates" ||
item === "el-date-editor--week" ||
item === "el-date-editor--month" ||
item === "el-date-editor--months" ||
item === "el-date-editor--year" ||
item === "el-date-editor--years" ||
item === "el-date-editor--daterange" ||
item === "el-date-editor--monthrange" ||
item === "el-date-editor--datetime" ||
item === "el-date-editor--datetimerange"
) {
type = "el-date-picker";
} else {
type = item;
}
}
});
res.push({
label: label,
required: required,
type: type,
disabled: disabled,
});
});
return res;
}
- 獲取表單校驗(yàn)失敗信息
/**
* 獲取全部表單報(bào)錯信息
* @param {wrapper}
* @returns {Array}
*/
export async function getFormErrors(wrapper) {
await wrapper.vm.$nextTick();
let result = [];
let formItems = $(
$("body").find(".el-form")[$("body").find(".el-form").length - 1],
).find(".el-form-item");
if (!formItems.length) {
formItems = $(
$(wrapper.html()).find(".el-form")[
$(wrapper.html()).find(".el-form").length - 1
],
).find(".el-form-item");
}
Array.from(formItems).forEach(formItem => {
let field = $(formItem).find(".el-form-item__label").attr("for");
let label = $(formItem).find(".el-form-item__label").text();
let error = $(formItem).find(".el-form-item__error").text().trim();
result.push({
field,
label,
error,
});
});
return result;
}
4、 UI測試
TODO文章來源:http://www.zghlxwxcb.cn/news/detail-717832.html
5、 E2E測試
TODO
到了這里,關(guān)于前端自動化測試(二)Vue Test Utils + Jest的文章就介紹完了。如果您還想了解更多內(nèi)容,請?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!