Jest 單元測試疑難點入門

momo707577045發表於2022-02-11

文章基調

  • 介紹概念及思考的過程,不提供程式碼(具體程式碼寫法可參考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 字串沒有區別

單元測試需要覆蓋那些場景?

  • 程式碼變動

    • 直接執行單元測試即可發現,但如何避免開發者忘記了執行單元測試?
    • 通過新增 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 報告說明

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 重寫了該方法
  • 終極方法,覆蓋整個第三方庫

    • 編寫替身檔案,在使用 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()
    

感謝

  • 近期文章產出少,事情太多,也懶了
  • 感謝網友的牽掛和督促,被人掛念的感覺真好

相關文章