關於前端單元測試,其實兩年前我就已經關注了,但那時候只是簡單的知道斷言
,想着也不是太難的東西,項目中也沒有用到,而後就想固然的認爲本身就會了。html
兩年後的今天,部門要對以往的項目補加單元測試。真到了開始着手的時候,卻懵了 😂前端
我覺得的我覺得卻把本身給坑了,我發現本身對於前端單元測試一無所知。而後我翻閱了大量的文檔,發現基於dva
的單元測試文檔比較少,所以在有了一番實踐以後,我梳理了幾篇文章,但願對於想使用 Jest 進行 React + Dva + Antd 單元測試
的你能有所幫助。文章內容力求深刻淺出,淺顯易懂~vue
介於內容所有收在一篇會太長,計劃分爲兩篇,本篇是第一篇,主要介紹如何快速上手jest
以及在實戰中經常使用的功能及api
在開始介紹jest
以前,我想有必要簡單闡述一下關於前端單元測試的一些基礎信息。node
在 2021 年的今天,構建一個複雜的web
應用對於咱們來講,並不是什麼難事。由於有足夠多優秀的的前端框架(好比 React
,Vue
);以及一些易用且強大的UI
庫(好比 Ant Design
,Element UI
)爲咱們保駕護航,極大地縮短了應用構建的週期。可是快速迭代的過程當中卻產生了大量的問題:代碼質量(可讀性差、可維護性低、可擴展性低)低,頻繁的產品需求變更(代碼變更影響範圍不可控)等。react
所以單元測試的概念在前端領域應運而生,經過編寫單元測試能夠確保獲得預期的結果,提升代碼的可讀性,若是依賴的組件有修改,受影響的組件也能在測試中及時發現錯誤。ios
測試類型又有哪些呢?git
通常常見的有如下四種:web
常見的開發模式呢?ajax
TDD
: 測試驅動開發BDD
: 行爲驅動測試針對項目自己使用的是React + Dva + Antd
的技術棧,單元測試咱們用的是Jest + Enzyme
結合的方式。express
Jest
關於Jest
,咱們參考一下其Jest 官網,它是Facebook
開源的一個前端測試框架,主要用於React
和React Native
的單元測試,已被集成在create-react-app
中。Jest
特色:
Enzyme
Enzyme
是Airbnb
開源的React
測試工具庫,提供了一套簡潔強大的API
,並內置Cheerio
,同時實現了jQuery
風格的方式進行DOM
處理,開發體驗十分友好。在開源社區有超高人氣,同時也得到了React
官方的推薦。
Jest
本篇文章咱們着重來介紹一下Jest
,也是咱們整個React單元測試
的根基。
安裝Jest
、Enzyme
。若是React
的版本是15
或者16
,須要安裝對應的enzyme-adapter-react-15
和enzyme-adapter-react-16
並配置。
/** * setup * */ import Enzyme from "enzyme" import Adapter from "enzyme-adapter-react-16" Enzyme.configure({ adapter: new Adapter() })
能夠運行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 的調用和實例 instancecollectCoverage
: 是否收集測試時的覆蓋率信息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 來進行比較,若是進行浮點數的比較,要使用 toBeCloseTonot
:取反toEqual(value)
:用於對象的深比較toContain(item)
:用來判斷 item 是否在一個數組中,也能夠用於字符串的判斷toBeNull(value)
:只匹配 nulltoBeUndefined(value)
:只匹配 undefinedtoBeDefined(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
提供了三種方案來測試異步代碼,下面咱們分別來看一下。
當咱們的test
函數中出現了異步回調函數時,能夠給test
函數傳入一個done
參數,它是一個函數類型的參數。若是test
函數傳入了done
,jest
就會等到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()
和toHaveBeenCalledTimes
、jest.advanceTimersByTime
等api
來處理這種場景。
這裏我也不舉例詳細說明了,有這方面需求的同窗能夠參考 Timer Mocks
⚠️ 當對Promise
進行測試時,必定要在斷言以前加一個return
,否則沒有等到Promise
的返回,測試函數就會結束。可使用.promises/.rejects
對返回的值進行獲取,或者使用then/catch
方法進行判斷。
若是代碼中使用了Promise
,則能夠經過返回Promise
來處理異步代碼,jest
會等該promise
的狀態轉爲resolve
時纔會結束,若是promise
被reject
了,則該測試用例不經過。
// 假設 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
實際上是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
,即快照也。一般涉及 UI 的自動化測試,思路是把某一時刻的標準狀態拍個快照。
describe("xxx頁面", () => { // beforeEach(() => { // jest.resetAllMocks() // }) // 使用 snapshot 進行 UI 測試 it("頁面應能正常渲染", () => { const wrapper = wrappedShallow() expect(wrapper).toMatchSnapshot() }) })
當使用toMatchSnapshot
的時候,Jest
將會渲染組件並建立其快照文件。這個快照文件包含渲染後組件的整個結構,而且應該與測試文件自己一塊兒提交到代碼庫。當咱們再次運行快照測試時,Jest
會將新的快照與舊的快照進行比較,若是二者不一致,測試就會失敗,從而幫助咱們確保用戶界面不會發生意外改變。
到這裏,關於前端單元測試的一些基礎背景和Jest
的基礎api
就介紹完了,在下一篇文章中,我會結合項目中的一個React組件
來說解如何作組件單元測試
。