文章基調
- 介紹概念及思考的過程,不提供程式碼(具體程式碼寫法可參考jest 官網)
延伸:
- 資訊大爆炸時代,各類資源很豐富,具體教程網上有很多資料
- 詳細不過官網,不重複製造相同的資訊,造成額外的心智負擔
- 大腦只是搜尋引擎,知道資源從那裡找,不負責記錄具體做法,節省記憶體
測試的幾個名稱
- 視覺測試:【測試工具】前端視覺較為多變,故視覺測試的成本較大,普及性不高,但好處在於,可以測試樣式資訊
- 單元測試:【測試目標】最小顆粒度的測試,針對單個函式或功能,適合函式庫,基礎元件庫等的測試
- 整合測試:【測試目標】模擬使用者操作,面向交付的最終結果,針對專案的流程
- TDD(Test Driven Development 測試驅動開發):【方法論】先寫測試用例(提出期望值),在寫具體的實現方法與函式,運用於單元測試
- BDD(Behavior Driven Development 行為驅動開發):【方法論】基於整合測試
本文主要介紹 jest(玩笑) 單元測試庫
jest 單元測試的原理與侷限性
先介紹原理,是希望讓大家知道其功能邊界,能做什麼,不能做什麼,瞭解能力範圍
jest 執行在 node 端,底層使用實現庫是 jsdom,使用 node 模擬一套 dom 環境,模擬的範圍僅侷限於 dom 層級結構及操作
【dom 操作】只模擬大部分 dom 通用功能,某些特定性的 dom api 並不支援,如 canvas,video 的媒體功能 api
- 如果要測試 canvas,video 的媒體 API,需要安裝對應的擴充套件庫,可以理解為在 node 端實現瀏覽器的功能,如圖片生成,音視訊播放等
- canvas 擴充套件,video 相關擴充套件暫時沒找到
【css 樣式】嚴格而言,沒有 css 樣式模擬功能,css 在 jsdom 中只當做純粹的 dom 屬性字串,與 id,class 字串沒有區別
- 不支援繼承,每個 dom 都是獨立的個體,沒有樣式上繼承。
- 僅支援內聯樣式,無法識別 vue 中的樣式
- 不太有用的,解析外鏈樣式的示例
- 這裡有個解決方案,但沒有得到官方合併
- 非內聯的樣式測試,需要使用視覺測試庫
單元測試需要覆蓋那些場景?
程式碼變動
- 直接執行單元測試即可發現,但如何避免開發者忘記了執行單元測試?
- 通過新增 cicd 流程解決,提交 merge request 申請時,觸發單元測試,執行失敗,則自動拒絕合併請求,並執行 node 命令傳送訊息提醒
- gitlab ci 相關配置會在文章末尾介紹
新增程式碼
- 新增的函式或者功能,執行舊的單元測試不會覆蓋到,如何提醒開發者覆蓋新增的這部分程式碼?
- 通過配置測試覆蓋率行數 100% 解決,達不到目標,則視為測試不通過,避免新增程式碼的遺漏。實在無法覆蓋的分支或函式,怎麼解決?
- 通過配置「忽略備註 / istanbul ignore next / 」,保持某檔案的百分百覆蓋率測試
後續有時間也可以通過全域性搜尋這些忽略配置,來逐個覆蓋測試,起到標記的作用
coverageThreshold: { './src/common/js/*.js': { branches: 80, // 覆蓋程式碼邏輯分支的百分比 functions: 80, // 覆蓋函式的百分比 lines: 80, // 覆蓋程式碼行的百分比 statements: -10 // 如果有超過 10 個未覆蓋的語句,則 jest 將失敗 } },
新增檔案,是否遺漏測試
- 一般情況下,單元測試只會跑單元測試檔案,新增的程式碼檔案沒有對應的測試檔案,會出現漏測的情況
通過
collectCoverageFrom
引數指定需要覆蓋的資料夾,當該資料夾中的檔案沒有對應的測試用例,會當作覆蓋率 0 處理,起到新檔案漏測提醒作用// 從那些資料夾中生成覆蓋率資訊,包括未設定對其編寫測試用例的檔案,解決遺漏新檔案的測試覆蓋問題 collectCoverageFrom: [ './src/common/js/*.{js,jsx}', './src/components/**/*.{js,vue}', ],
特殊場景(經驗的價值)
- 部分函式,在正常情況下執行是沒有問題的,僅在特殊的情況下才會報錯,如簡單的加法運算,放在小數中就會出現計算誤差,
0.1 + 0.2 = 0.30000000000000004
- 這些特殊場景的覆蓋,只能靠一線開發人員在實際工作中記錄,需要時間的積累
- 這是程式設計師經驗的價值,也是少有的,值得傳承的部分
- 部分函式,在正常情況下執行是沒有問題的,僅在特殊的情況下才會報錯,如簡單的加法運算,放在小數中就會出現計算誤差,
單元測試忽略原理
jest 收集覆蓋率底層使用的是 istanbul 庫(istanbul:伊斯坦布林,勝產地毯,地毯用於覆蓋),以下忽略格式都是 istanbul 庫的功能
- 忽略本檔案,放在檔案頂部 / istanbul ignore file /
- 忽略一個函式, 一塊分支邏輯或者一行程式碼,放在函式頂部 / istanbul ignore next /
- 忽略函式引數預設值
function getWeekRange(/* istanbul ignore next */ date = new Date()) {
- 具體忽略規則可檢視 istanbul github 介紹
編寫測試用例的正確姿勢
以對功能的期望及定位作為出發點,而不是程式碼,一開始應先思考該函式或工具庫需要起到的功能,而不應該一開始就看程式碼
- 先羅列你期望的,該元件或者函式的功能,用文字寫出來,這也是
test('檢測點選事件')
中描述的作用,告知他人這個測試用例的目的 - 編寫相應的測試用例
- 對不滿足測試用例的程式碼進行修改
- 觀察程式碼覆蓋率,覆蓋所有程式碼行
新增 jest 全域性自定義函式
- 如果某測試函式的出現頻率比較高,可以考慮對齊進行復用,寫成一個預載入檔案,在每個測試檔案執行前,載入該檔案
- 如獲取 dom 樣式的原始程式碼比較繁瑣,
wrapper.element.style.height
,且 element 並沒有得到官方暴露,屬於內部變數 可以通過新增配置檔案,編寫 styles 全域性方法,通過函式的方式獲取 style 資料,與 classes 等方法保持統一
// jest.config.js 設定前置執行檔案,在每個測試檔案執行前,會執行該檔案,實現新增某些全域性方法的作用 setupFilesAfterEnv: ['./jest.setup.js'],
// ./jest.setup.js import { shallowMount } from '@vue/test-utils' // 向全域性 wrapper 掛載通用函式 styles,返回該元素的內聯樣式(因為 jsdom 只支援內聯樣式,不支援檢測 class 中的樣式),或某內聯樣式的值 function addStylesFun() { // 生成一個臨時元件,獲取 vueWrapper 及 domWrapper 例項,掛載 style 方法 const vueWrapper = shallowMount({ template: '<div>componentForCreateWrapper</div>' }) const domWrapper = vueWrapper.find('div') vueWrapper.__proto__.styles = function(styleName) { return styleName ? this.element.style[styleName] : this.element.style } domWrapper.__proto__.styles = function(styleName) { return styleName ? this.element.style[styleName] : this.element.style } } addStylesFun()
鉤子函式
類似於 vue router 裡面的守衛函式,在進入前後執行鉤子函式
- 解決有狀態函式的資料儲存問題,避免執行每一個測試用例時,重複編寫程式碼準備資料
beforeAll、afterAll
- 寫在單元測試檔案最外部,則代表在該函式在檔案執行前、後被執行一次
- 寫在測試組 describe 最外層,代表該函式在測試組執行前、後被執行一次
beforeEach、afterEach
- 每個測試用例(test)前後執行一次
快速單元測試技巧
跳過已測試成功且原始碼沒發生過變更的用例,不再多餘執行
第一步,jest --watchAll 測試檔案發生過變化,則自動執行測試
- 只能在 package script 命令中新增該引數,在 npm 命令後執行不生效
- 原始碼變更,或單元測試檔案變更,都會觸發
第二步,按下 f(只執行錯誤的用例)
- 缺點在於,不能監控已執行成功的單元測試的變化,以及對應原始碼的變化,(即之前成功過的都會被忽略,不管新的變化,是否發生了錯誤)
- 原始碼變更,或單元測試檔案變更,都會觸發
- 可通過反覆按下 f 來切換全域性遍歷
第三步,再按下 o (只執行原始碼發生過變化的檔案的測試用例)
- 等價於 jest --watch
- 只監聽 git 中,未提交到暫存區的檔案,一旦提交了 stash,則不再觸發
- 即使該檔案中存在失敗的測試用例,也會被忽略
- 按下 a 來跑全部檔案的測試用例,即 a 與 o 的切換
- 底層是通讀取 .git 資料夾的內容進行檔案區分,故依賴 git 的存在
按下 w 可以顯示選單,檢視 watch 的選項
一般情況下,集合 o 與 f 使用,先 o(忽略沒變化的檔案,當我們改動該檔案時,將會被監聽。再反覆按下 f,只監聽錯誤的用例)
jest 報告說明
- 滑鼠懸浮對應圖表,即可顯示對應提示
- 「5x」表示在測試中這條語句執行了 5 次
- 「I」是測試用例 if 條件未進入,即沒有 if 為真的測試用例
「E」是測試用例沒有測試 if 條件為 false 時的情況
- 即測試用例中 if 條件一直都是 true,得寫一個 if 條件為 false 的測試用例,即不走進 if 條件裡面的程式碼,這個 E 才會消失
模擬函式,不是模擬資料的函式
- 只是模擬函式(Function、jest.fn()),並不是像 mockjs 一樣,生成模擬資料的函式
作用:
- 檢測該函式被執行過多少次
- 檢測該函式被執行時的 this 指向
- 檢測執行時的入參
- 檢測執行後的返回值
覆蓋模擬第三方函式
- 覆蓋 axios 函式,避免真正發起介面,定製特定的返回值
jest.mock('axios'); axios.get.mockResolvedValue(resp);
- 裡面沒有魔法,也沒有私下適配,只是單純的函式過載。相當於
axios.get = ()=> resp
重寫了該方法
- 覆蓋 axios 函式,避免真正發起介面,定製特定的返回值
終極方法,覆蓋整個第三方庫
- 編寫替身檔案,在使用 import 匯入時,匯入的是替身檔案
- 也可以通過 jest.requireActual('../foo-bar-baz') 來強制設定匯入的是真實的檔案,不使用替身檔案
計時器模擬
- 複寫 setTimeout 計時器,可以跳過指定時長,縮短單元測試執行時長
測試快照
- 快照,即資料副本,即檢測「當前資料」是否與「舊有資料副本」相同,類似於
JSON.stringify()
,進行資料的序列化記錄 應用場景
- 限制配置檔案的變更
- 檢測 dom 結構的比較,某函式的變更,是否影響 dom 結構
- 總體而言,用在大資料的比較操作,避免將資料寫死在單元測試檔案中
其他疑難雜症
別名與等價的方法
- it 是 test 的別名,兩者等價
- toBeTruthy !== toBe(true)、toBeFalsy !== toBe(false),toBe(true) 更嚴格,toBeTruthy 是強轉為 boolean 後,是否為真
- skip 跳過某測試用例,比註釋更優雅
describe.skip('測試自定義指令',xxx)
`test.skip('測試自定義指令',xxx)`
jest toBe,內部使用 Object.is 進行比較
- 與 === 的區別是,除了 NaN,+0 和 -0 之外,其行為與三等號於運算子相同
- 解決小數點浮點數計算誤差問題
toBeCloseTo
非同步測試,通過 .resolves / .rejects 強制校驗 promise 走特定分支
test('the data is peanut butter', () => { return expect(fetchData()).resolves.toBe('peanut butter'); });
解決預設引數為 new Date 的覆蓋問題
test('當前月,測試引數 new Date 預設值', () => { // 覆寫 new Date 的值,模擬為 2022/01/23 17:13:03 ,解決預設引數為 new Date 時,無法覆蓋的問題 const mockDate = new Date(1642938133000) const spyDate = jest .spyOn(global, 'Date') // 即監聽 global.Date 變數 .mockImplementationOnce(() => { spyDate.mockRestore() // 需要在第一次執行後,馬上消除該 mock,避免後續影響後續 new Date return mockDate }) let [starTime, endTime] = getMonthRange() expect(starTime).toBe(1640966400000) // 2022/01/01 00:00:00 expect(endTime).toBe(1643644799000) // 2022/01/31 23:59:59 })
等價於使用原生語法寫
const OriginDate = global.Date Date = jest.fn(() => { Date = OriginDate return new OriginDate(1642938133000) })
使用最新語法
beforeAll(() => { jest.useFakeTimers('modern') jest.setSystemTime(new Date(1466424490000)) // 因為 Vue Test Utils 中使用的 jest 是 24.9 的版本,沒有該函式 }) afterEach(() => { jest.restoreAllMocks() })
匹配測試,及使用多個批次的資料,進行跑同一個測試用例
describe.each([ [1, 1, 2], // 每一行是代表執行一次測試用例 [1, 2, 3], // 每一行中的引數,是執行該次測試用例是用到的資料,前兩個是引數,第三個是測試期望值 [2, 1, 3], ])( '.add(%i, %i)', // 設定 describe 的標題,%i 是 print 的變數引數 (a, b, expected) => { test(`returns ${expected}`, () => { expect(a + b).toBe(expected); }); });
gitlab-ci 單元測試相關配置
- 在發起 merge 合併請求時,觸發 ci 執行單元測試
當單元測試失敗,執行 node 檔案,傳送飛書資訊,飛書資訊中,包括該次 merge 請求的連結,可以點選該連結,快速定位到單元測試 job,檢視問題
stages: - merge-unit-test - merge-unit-test-fail-callback - other-test # merge 請求時執行的 job step-merge: stage: merge-unit-test # 使用的 gitlab runner tags: [front-end] # 僅在提出程式碼合併請求時執行 only: [merge_requests] # 排除特定分支的程式碼合併請求,即在特定分支的程式碼合併請求時,不執行該 job except: variables: - $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "qa" # 執行的命令 script: - npm install --registry=https://registry.npm.taobao.org # 安裝依賴 # 2>&1 標準錯誤定向到標準輸出 # Linux tee 命令用於讀取標準輸入的資料,並將其內容輸出成檔案。 - npm run test 2>&1 | tee ci-merge-unit-test.log # 執行單元測試,並將在控制檯輸出的資訊,儲存在 ci-merge-unit-test.log 檔案中,以便後續分析 - echo 'merge-unit-test-finish' # 定義往下一個 job 需要傳遞的資料 artifacts: when: on_failure # 預設情況下,只會在 success 儲存,可以通過這個識別符號進行配置 paths: # 定義需要傳遞的檔案 - ci-merge-unit-test.log # merge 檢測失敗時執行的 node 命令 step-merge-unit-test-fail-callback: stage: merge-unit-test-fail-callback # 當上一個 job 執行失敗時,才會觸發 when: on_failure tags: [front-end] only: [merge_requests] except: variables: - $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "qa" script: - node ci-merge-unit-test-fail-callback.js $CI_PROJECT_NAME $CI_JOB_ID # 執行 node 指令碼,進行飛書通知,並攜帶對應連結,進行快速定位
ci-merge-unit-test-fail-callback.js.js
const fs = require('fs') const path = require('path') const https = require('https') const projectName = process.argv[2] // 專案名 const jobsId = process.argv[3] // 執行的 ci 任務 id const logsMainMsg = fs.readFileSync(path.join(__dirname, 'ci-merge-unit-test.log')) .toString() .split('\n') .filter(line => line[line.length - 1] !== '|' && line.indexOf('PASS ') !== 0) // 過濾不關注的資訊 .join('\n') const data = JSON.stringify({ msg_type: 'post', content: { post: { zh_cn: { content: [ [ { tag: 'a', text: 'gitlab merge 單元測試', href: `https://xxx/fe-team/${projectName}/-/jobs/${Number(jobsId) - 1}` }, { tag: 'text', text: `執行失敗\r\n${logsMainMsg}` } ] ] } } } }) const req = https.request({ hostname: 'open.feishu.cn', port: 443, path: '/open-apis/bot/v2/hook/xxx', method: 'POST', headers: { 'Content-Type': 'application/json' } }, res => { console.log(`statusCode: ${res.statusCode}`) res.on('data', d => process.stdout.write(d)) }) req.on('error', error => console.error(error)) req.write(data) req.end()
感謝
- 近期文章產出少,事情太多,也懶了
- 感謝網友的牽掛和督促,被人掛念的感覺真好