那些年錯過的React組件單元測試(上)

🏂 寫在前面

關於前端單元測試,其實兩年前我就已經關注了,但那時候只是簡單的知道斷言,想着也不是太難的東西,項目中也沒有用到,而後就想固然的認爲本身就會了。html

兩年後的今天,部門要對以往的項目補加單元測試。真到了開始着手的時候,卻懵了 😂前端

我覺得的我覺得卻把本身給坑了,我發現本身對於前端單元測試一無所知。而後我翻閱了大量的文檔,發現基於dva的單元測試文檔比較少,所以在有了一番實踐以後,我梳理了幾篇文章,但願對於想使用 Jest 進行 React + Dva + Antd 單元測試的你能有所幫助。文章內容力求深刻淺出,淺顯易懂~vue

介於內容所有收在一篇會太長,計劃分爲兩篇,本篇是第一篇,主要介紹如何快速上手 jest以及在實戰中經常使用的功能及 api

🏈 前端自動化測試產生的背景

在開始介紹jest以前,我想有必要簡單闡述一下關於前端單元測試的一些基礎信息。node

  • 爲何要進行測試?

    在 2021 年的今天,構建一個複雜的web應用對於咱們來講,並不是什麼難事。由於有足夠多優秀的的前端框架(好比 ReactVue);以及一些易用且強大的UI庫(好比 Ant DesignElement UI)爲咱們保駕護航,極大地縮短了應用構建的週期。可是快速迭代的過程當中卻產生了大量的問題:代碼質量(可讀性差、可維護性低、可擴展性低)低,頻繁的產品需求變更(代碼變更影響範圍不可控)等。react

    所以單元測試的概念在前端領域應運而生,經過編寫單元測試能夠確保獲得預期的結果,提升代碼的可讀性,若是依賴的組件有修改,受影響的組件也能在測試中及時發現錯誤。ios

  • 測試類型又有哪些呢?git

    通常常見的有如下四種:web

    • 單元測試
    • 功能測試
    • 集成測試
    • 冒煙測試
  • 常見的開發模式呢?ajax

    • TDD: 測試驅動開發
    • BDD: 行爲驅動測試

🎮 技術方案

針對項目自己使用的是React + Dva + Antd的技術棧,單元測試咱們用的是Jest + Enzyme結合的方式。express

Jest

關於Jest,咱們參考一下其Jest 官網,它是Facebook開源的一個前端測試框架,主要用於ReactReact Native的單元測試,已被集成在create-react-app中。Jest特色:

  • 零配置
  • 快照
  • 隔離
  • 優秀的 api
  • 快速且安全
  • 代碼覆蓋率
  • 輕鬆模擬
  • 優秀的報錯信息

Enzyme

EnzymeAirbnb開源的React測試工具庫,提供了一套簡潔強大的API,並內置Cheerio,同時實現了jQuery風格的方式進行DOM處理,開發體驗十分友好。在開源社區有超高人氣,同時也得到了React官方的推薦。

📌 Jest

本篇文章咱們着重來介紹一下Jest,也是咱們整個React單元測試的根基。

環境搭建

安裝

安裝JestEnzyme。若是React的版本是15或者16,須要安裝對應的enzyme-adapter-react-15enzyme-adapter-react-16並配置。

/**
 * setup
 *
 */

import Enzyme from "enzyme"
import Adapter from "enzyme-adapter-react-16"
Enzyme.configure({ adapter: new Adapter() })

jest.config.js

能夠運行npx jest --init在根目錄生成配置文件jest.config.js

/*
 * For a detailed explanation regarding each configuration property, visit:
 * https://jestjs.io/docs/en/configuration.html
 */

module.exports = {
  // All imported modules in your tests should be mocked automatically
  // automock: false,

  // Automatically clear mock calls and instances between every test
  clearMocks: true,

  // Indicates whether the coverage information should be collected while executing the test
  // collectCoverage: true,

  // An array of glob patterns indicating a set of files for which coverage information should be collected
  collectCoverageFrom: ["src/**/*.{js,jsx,ts,tsx}", "!src/**/*.d.ts"],

  // The directory where Jest should output its coverage files
  coverageDirectory: "coverage",

  // An array of regexp pattern strings used to skip coverage collection
  // coveragePathIgnorePatterns: [
  //   "/node_modules/"
  // ],


  // An array of directory names to be searched recursively up from the requiring module's location
  moduleDirectories: ["node_modules", "src"],

  // An array of file extensions your modules use
  moduleFileExtensions: ["js", "json", "jsx", "ts", "tsx"],


  // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
  // modulePathIgnorePatterns: [],

  // Automatically reset mock state between every test
  // resetMocks: false,

  // Reset the module registry before running each individual test
  // resetModules: false,

  // Automatically restore mock state between every test
  // restoreMocks: false,

  // The root directory that Jest should scan for tests and modules within
  // rootDir: undefined,

  // A list of paths to directories that Jest should use to search for files in
  // roots: [
  //   "<rootDir>"
  // ],

  // The paths to modules that run some code to configure or set up the testing environment before each test
  // setupFiles: [],

  // A list of paths to modules that run some code to configure or set up the testing framework before each test
  setupFilesAfterEnv: [
    "./node_modules/jest-enzyme/lib/index.js",
    "<rootDir>/src/utils/testSetup.js",
  ],

  // The test environment that will be used for testing
  testEnvironment: "jest-environment-jsdom",

  // Options that will be passed to the testEnvironment
  // testEnvironmentOptions: {},

  // The glob patterns Jest uses to detect test files
  testMatch: ["**/?(*.)+(spec|test).[tj]s?(x)"],

  // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
  // testPathIgnorePatterns: [
  //   "/node_modules/"
  // ],


  // A map from regular expressions to paths to transformers
  // transform: undefined,

  // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
  transformIgnorePatterns: ["/node_modules/", "\\.pnp\\.[^\\/]+$"],
}

這裏只是列舉了經常使用的配置項:

  • automock: 告訴 Jest 全部的模塊都自動從 mock 導入.
  • clearMocks: 在每一個測試前自動清理 mock 的調用和實例 instance
  • collectCoverage: 是否收集測試時的覆蓋率信息
  • collectCoverageFrom: 生成測試覆蓋報告時檢測的覆蓋文件
  • coverageDirectory: Jest 輸出覆蓋信息文件的目錄
  • coveragePathIgnorePatterns: 排除出 coverage 的文件列表
  • coverageReporters: 列出包含 reporter 名字的列表,而 Jest 會用他們來生成覆蓋報告
  • coverageThreshold: 測試能夠容許經過的閾值
  • moduleDirectories: 模塊搜索路徑
  • moduleFileExtensions:表明支持加載的文件名
  • testPathIgnorePatterns:用正則來匹配不用測試的文件
  • setupFilesAfterEnv:配置文件,在運行測試案例代碼以前,Jest 會先運行這裏的配置文件來初始化指定的測試環境
  • testMatch: 定義被測試的文件
  • transformIgnorePatterns: 設置哪些文件不須要轉譯
  • transform: 設置哪些文件中的代碼是須要被相應的轉譯器轉換成 Jest 能識別的代碼,Jest 默認是能識別 JS 代碼的,其餘語言,例如 Typescript、CSS 等都須要被轉譯。

匹配器

  • toBe(value):使用 Object.is 來進行比較,若是進行浮點數的比較,要使用 toBeCloseTo
  • not:取反
  • toEqual(value):用於對象的深比較
  • toContain(item):用來判斷 item 是否在一個數組中,也能夠用於字符串的判斷
  • toBeNull(value):只匹配 null
  • toBeUndefined(value):只匹配 undefined
  • toBeDefined(value):與 toBeUndefined 相反
  • toBeTruthy(value):匹配任何語句爲真的值
  • toBeFalsy(value):匹配任何語句爲假的值
  • toBeGreaterThan(number): 大於
  • toBeGreaterThanOrEqual(number):大於等於
  • toBeLessThan(number):小於
  • toBeLessThanOrEqual(number):小於等於
  • toBeInstanceOf(class):判斷是否是 class 的實例
  • resolves:用來取出 promise 爲 fulfilled 時包裹的值,支持鏈式調用
  • rejects:用來取出 promise 爲 rejected 時包裹的值,支持鏈式調用
  • toHaveBeenCalled():用來判斷 mock function 是否被調用過
  • toHaveBeenCalledTimes(number):用來判斷 mock function 被調用的次數
  • assertions(number):驗證在一個測試用例中有 number 個斷言被調用

命令行工具的使用

在項目package.json文件添加以下script:

"scripts": {
    "start": "node bin/server.js",
    "dev": "node bin/server.js",
    "build": "node bin/build.js",
    "publish": "node bin/publish.js",
++  "test": "jest --watchAll",
},

此時運行npm run test:

咱們發現有如下幾種模式:

  • f: 只會測試以前沒有經過的測試用例
  • o: 只會測試關聯的而且改變的文件(須要使用 git)(jest --watch 能夠直接進入該模式)
  • p: 測試文件名包含輸入的名稱的測試用例
  • t: 測試用例的名稱包含輸入的名稱的測試用例
  • a: 運行所有測試用例

在測試過程當中,你能夠切換適合的模式。

鉤子函數

相似於 react 或者 vue 的生命週期,一共有四種:

  • beforeAll():全部測試用例執行以前執行的方法
  • afterAll():全部測試用例跑完之後執行的方法
  • beforeEach():在每一個測試用例執行以前須要執行的方法
  • afterEach():在每一個測試用例執行完後執行的方法

這裏,我以項目中的一個基礎 demo 來演示一下具體使用:

Counter.js

export default class Counter {
  constructor() {
    this.number = 0
  }
  addOne() {
    this.number += 1
  }
  minusOne() {
    this.number -= 1
  }
}

Counter.test.js

import Counter from './Counter'
const counter = new Counter()

test('測試 Counter 中的 addOne 方法', () => {
  counter.addOne()
  expect(counter.number).toBe(1)
})

test('測試 Counter 中的 minusOne 方法', () => {
  counter.minusOne()
  expect(counter.number).toBe(0)
})

運行npm run test:

經過第一個測試用例加 1,number的值爲 1,當第二個用例減 1 的時候,結果應該是 0。可是這樣兩個用例間相互干擾很差,能夠經過 Jest 的鉤子函數來解決。修改測試用例:

import Counter from "../../../src/utils/Counter";
let counter = null

beforeAll(() => {
  console.log('BeforeAll')
})

beforeEach(() => {
  console.log('BeforeEach')
  counter = new Counter()
})

afterEach(() => {
  console.log('AfterEach')
})

afterAll(() => {
  console.log('AfterAll')
})

test('測試 Counter 中的 addOne 方法', () => {
  counter.addOne()
  expect(counter.number).toBe(1)
})
test('測試 Counter 中的 minusOne 方法', () => {
  counter.minusOne()
  expect(counter.number).toBe(-1)
})

運行npm run test:

能夠清晰的看到對應鉤子的執行順序:

beforeAll > (beforeEach > afterEach)(單個用例都會依次執行) > afterAll

除了以上這些基礎知識外,其實還有異步代碼的測試、Mock、Snapshot 快照測試等,這些咱們會在下面 React 的單元測試示例中依次講解。

異步代碼的測試

衆所周知,JS中充滿了異步代碼。

正常狀況下測試代碼是同步執行的,但當咱們要測的代碼是異步的時候,就會有問題了:test case實際已經結束了,然而咱們的異步代碼尚未執行,從而致使異步代碼沒有被測到。

那怎麼辦呢?

對於當前測試代碼來講,異步代碼何時執行它並不知道,所以解決方法很簡單。當有異步代碼的時候,測試代碼跑完同步代碼後不當即結束,而是等結束的通知,當異步代碼執行完後再告訴jest:「好了,異步代碼執行完了,你能夠結束任務了」。

jest提供了三種方案來測試異步代碼,下面咱們分別來看一下。

done 關鍵字

當咱們的test函數中出現了異步回調函數時,能夠給test函數傳入一個done參數,它是一個函數類型的參數。若是test函數傳入了donejest就會等到done被調用纔會結束當前的test case,若是done沒有被調用,則該test自動不經過測試。

import { fetchData } from './fetchData'
test('fetchData 返回結果爲 { success: true }', done => {
  fetchData(data => {
    expect(data).toEqual({
      success: true
    })
    done()
  })
})

上面的代碼中,咱們給test函數傳入了done參數,在fetchData的回調函數中調用了done。這樣,fetchData的回調中異步執行的測試代碼就可以被執行。

但這裏咱們思考一種場景:若是使用done來測試回調函數(包含定時器場景,如setTimeout),因爲定時器咱們設置了 必定的延時(如 3s)後執行,等待 3s 後會發現測試經過了。那假如 setTimeout 設置爲幾百秒,難道咱們也要在 Jest 中等幾百秒後再測試嗎?

顯然這對於測試的效率是大打折扣的!!

jest中提供了諸如jest.useFakeTimers()jest.runAllTimers()toHaveBeenCalledTimesjest.advanceTimersByTimeapi來處理這種場景。

這裏我也不舉例詳細說明了,有這方面需求的同窗能夠參考 Timer Mocks

返回 Promise

⚠️ 當對 Promise進行測試時,必定要在斷言以前加一個 return,否則沒有等到 Promise的返回,測試函數就會結束。可使用 .promises/.rejects對返回的值進行獲取,或者使用 then/catch方法進行判斷。

若是代碼中使用了Promise,則能夠經過返回Promise來處理異步代碼,jest會等該promise的狀態轉爲resolve時纔會結束,若是promisereject了,則該測試用例不經過。

// 假設 user.getUserById(參數id) 返回一個promise
it('測試promise成功的狀況', () => {
  expect.assertions(1);
  return user.getUserById(4).then((data) => {
    expect(data).toEqual('Cosen');
  });
});
it('測試promise錯誤的狀況', () => {
  expect.assertions(1);
  return user.getUserById(2).catch((e) => {
    expect(e).toEqual({
      error: 'id爲2的用戶不存在',
    });
  });
});

注意,上面的第二個測試用例可用於測試promise返回reject的狀況。這裏用.catch來捕獲promise返回的reject,當promise返回reject時,纔會執行expect語句。而這裏的expect.assertions(1)用於確保該測試用例中有一個expect被執行了。

對於Promise的狀況,jest還提供了一對匹配符resolves/rejects,其實只是上面寫法的語法糖。上面的代碼用匹配符能夠改寫爲:

// 使用'.resolves'來測試promise成功時返回的值
it('使用'.resolves'來測試promise成功的狀況', () => {
  return expect(user.getUserById(4)).resolves.toEqual('Cosen');
});
// 使用'.rejects'來測試promise失敗時返回的值
it('使用'.rejects'來測試promise失敗的狀況', () => {
  expect.assertions(1);
  return expect(user.getUserById(2)).rejects.toEqual({
    error: 'id爲2的用戶不存在',
  });
});

async/await

咱們知道async/await實際上是Promise的語法糖,能夠更優雅地寫異步代碼,jest中也支持這種語法。

咱們把上面的代碼改寫一下:

// 使用async/await來測試resolve
it('async/await來測試resolve', async () => {
  expect.assertions(1);
  const data = await user.getUserById(4);
  return expect(data).toEqual('Cosen');
});
// 使用async/await來測試reject
it('async/await來測試reject', async () => {
  expect.assertions(1);
  try {
    await user.getUserById(2);
  } catch (e) {
    expect(e).toEqual({
      error: 'id爲2的用戶不存在',
    });
  }
});
⚠️ 使用 async不用進行 return返回,而且要使用 try/catch來對異常進行捕獲。

Mock

介紹jest中的mock以前,咱們先來思考一個問題:爲何要使用mock函數?

在項目中,一個模塊的方法內經常會去調用另一個模塊的方法。在單元測試中,咱們可能並不須要關心內部調用的方法的執行過程和結果,只想知道它是否被正確調用便可,甚至會指定該函數的返回值。這個時候,mock的意義就很大了。

jest中與mock相關的api主要有三個,分別是jest.fn()jest.mock()jest.spyOn()。使用它們建立mock函數可以幫助咱們更好的測試項目中一些邏輯較複雜的代碼。咱們在測試中也主要是用到了mock函數提供的如下三種特性:

  • 捕獲函數調用狀況
  • 設置函數返回值
  • 改變函數的內部實現

下面,我將分別介紹這三種方法以及他們在實際測試中的應用。

jest.fn()

jest.fn()是建立mock函數最簡單的方式,若是沒有定義函數內部的實現,jest.fn()會返回undefined做爲返回值。

// functions.test.js

test('測試jest.fn()調用', () => {
  let mockFn = jest.fn();
  let res = mockFn('廈門','青島','三亞');

  // 斷言mockFn的執行後返回undefined
  expect(res).toBeUndefined();
  // 斷言mockFn被調用
  expect(mockFn).toBeCalled();
  // 斷言mockFn被調用了一次
  expect(mockFn).toBeCalledTimes(1);
  // 斷言mockFn傳入的參數爲1, 2, 3
  expect(mockFn).toHaveBeenCalledWith('廈門','青島','三亞');
})

jest.fn()所建立的mock函數還能夠設置返回值,定義內部實現返回Promise對象

// functions.test.js

test('測試jest.fn()返回固定值', () => {
  let mockFn = jest.fn().mockReturnValue('default');
  // 斷言mockFn執行後返回值爲default
  expect(mockFn()).toBe('default');
})

test('測試jest.fn()內部實現', () => {
  let mockFn = jest.fn((num1, num2) => {
    return num1 + num2;
  })
  // 斷言mockFn執行後返回20
  expect(mockFn(10, 10)).toBe(20);
})

test('測試jest.fn()返回Promise', async () => {
  let mockFn = jest.fn().mockResolvedValue('default');
  let res = await mockFn();
  // 斷言mockFn經過await關鍵字執行後返回值爲default
  expect(res).toBe('default');
  // 斷言mockFn調用後返回的是Promise對象
  expect(Object.prototype.toString.call(mockFn())).toBe("[object Promise]");
})

jest.mock()

通常在真實的項目裏,測試異步函數的時候,不會真正的發送 ajax 請求去請求這個接口,爲何?

好比有 1w 個接口要測試,每一個接口要 3s 才能返回,測試所有接口須要 30000s,那麼這個自動化測試的時間就太慢了

咱們做爲前端只須要去確認這個異步請求發送成功就行了,至於後端接口返回什麼內容咱們就不測了,這是後端自動化測試要作的事情。

這裏以一個axios請求demo爲例來講明:

// user.js
import axios from 'axios'

export const getUserList = () => {
  return axios.get('/users').then(res => res.data)
}

對應測試文件user.test.js:

import { getUserList } from '@/services/user.js'
import axios from 'axios'
// 👇👇
jest.mock('axios')
// 👆👆
test.only('測試 getUserList', async () => {
  axios.get.mockResolvedValue({ data: ['Cosen','森林','柯森'] })
  await getUserList().then(data => {
    expect(data).toBe(['Cosen','森林','柯森'])
  })
})

咱們在測試用例的最上面加入了jest.mock('axios'),咱們讓jest去對axios作模擬,這樣就不會去請求真正的數據了。而後調用axios.get的時候,不會真實的請求這個接口,而是會以咱們寫的{ data: ['Cosen','森林','柯森'] }去模擬請求成功後的結果。

固然模擬異步請求是須要時間的,若是請求多的話時間就很長,這時候能夠在本地 mock數據,在根目錄下新建 __mocks__文件夾。這種方式就不用去模擬 axios,而是直接走的本地的模擬方法,也是比較經常使用的一種方式,這裏就不展開說明了。

jest.spyOn()

jest.spyOn()方法一樣建立一個mock函數,可是該mock函數不只可以捕獲函數的調用狀況,還能夠正常的執行被spy的函數。實際上,jest.spyOn()jest.fn()的語法糖,它建立了一個和被spy的函數具備相同內部代碼的mock函數

Snapshot 快照測試

所謂snapshot,即快照也。一般涉及 UI 的自動化測試,思路是把某一時刻的標準狀態拍個快照。

describe("xxx頁面", () => {
  // beforeEach(() => {
  //   jest.resetAllMocks()
  // })
  // 使用 snapshot 進行 UI 測試
  it("頁面應能正常渲染", () => {
    const wrapper = wrappedShallow()
    expect(wrapper).toMatchSnapshot()
  })
})

當使用toMatchSnapshot的時候,Jest 將會渲染組件並建立其快照文件。這個快照文件包含渲染後組件的整個結構,而且應該與測試文件自己一塊兒提交到代碼庫。當咱們再次運行快照測試時,Jest 會將新的快照與舊的快照進行比較,若是二者不一致,測試就會失敗,從而幫助咱們確保用戶界面不會發生意外改變。

🎯 總結

到這裏,關於前端單元測試的一些基礎背景和Jest的基礎api就介紹完了,在下一篇文章中,我會結合項目中的一個React組件來說解如何作組件單元測試

📜 參考連接

相關文章
相關標籤/搜索