一篇文章搞定前端單元測試框架 Jest
前言
雖然有很多前端團隊壓根現在甚至未來都不太可能使用單元測試,包括我自己的團隊,原因無非是耽誤時間,開發任務本身就比較重等等理由。
但是我覺得一味的圖快,永遠是飲鴆止渴,陷入惡性循環,項目快 \--> 代碼爛 \--> 修改和加功能花費更多的時間和精力 \--> 來不及做優化必須更快 \--> 項目快 \--> 代碼爛 \-->
... 無限循環。
這就是做單元測試我認爲最重要的原因就是,重構代碼時,確認功能沒有問題,不怕人員流動,功能遷移,最主要的是跟產品撕b
,測試用例就是最好的證據😁。
業務項目用不到的話,如果你寫庫,不寫單測,可能用的同學都會有所顧忌,所以會寫單測是對高級以上前端必備的技能。
單元測試框架基本原理
例如如下的一個測試用例,感受一下基本的樣子長啥,我們後面會把其中用到的方法自己實現一個簡單版本
// 意思是字符串hello是否包含ll
test('測試字符串中是否包含 ll'), () => {
expect(findStr('hello')).toMatch('ll')
})
function findStr(str){
return `${str} world`
}
複製代碼
我們可以簡單的實現一下上面測試用例用到的方法,test、expect、toMatch
,這樣就算掌握了基本的測試框架原理
test
function test(desc, fn){
try{
fn();
console.log(`✅ 通過測試用例`)
}catch{
console.log(`❌ 沒有通過測試用例`)
}
}
複製代碼
expect、toMatch
function expect(ret){
return {
toMatch(expRet){
if(typeof ret === 'string'){ throw Error('') }
if(!ret.includes(expRet)){ throw Error('') }
}
}
}
複製代碼
jest 基本配置
必備工具:
$ npm i -D jest babel-jest ts-jest @types/jest
複製代碼
參考配置 jest.config.js,測試文件均放在 tests 目錄中:下面的 testRegex 表示匹配的 tests 文件夾下的以 test 或者 spec 結尾的 jsx 或者 tsx 文件
module.exports = {
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
testRegex: '(/tests/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['tsx', 'ts', 'js', 'jsx', 'json', 'node'],
};
複製代碼
最後在 package.json 的 scripts 中加入
{
test: "jest"
// 如果要測試覆蓋率,後面加上--coverage
// 如果要監聽所有測試文件 --watchAll
}
複製代碼
匹配器
匹配器(Matchers)是 Jest 中非常重要的一個概念,它可以提供很多種方式來讓你去驗證你所測試的返回值。舉個例子就明白什麼是匹配器了。
這裏的匹配器掃一眼即可,大概知道有那麼回事,用的時候查你想要的匹配器就行,不用刻意去記憶。
相等匹配,這是我們最常用的匹配規則
test('two plus two is four', () => {
expect(2 + 2).toBe(4);
});
複製代碼
在這段代碼中 expact(2 + 2)
將返回我們期望的結果,通常情況下我們只需要調用expect
就可以,括號中的可以是一個具有返回值的函數,也可以是表達式。後面的toBe
就是一匹配器。
下面列舉一些常用的匹配器:
普通匹配器
- toBe:object.is 相當於 ===
test('測試加法 3 + 7', () => {
// toBe 匹配器 matchers object.is 相當於 ===
expect(10).toBe(10)
})
複製代碼
- toEqual:內容相等,匹配內容,不匹配引用
test('toEqual 匹配器', () => {
// toEqual 匹配器 只會匹配內容,不會匹配引用
const a = { one: 1 }
expect(a).toEqual({ one: 1 })
})
複製代碼
與真假有關的匹配器
-
真假
-
toBeNull:只匹配 Null
test('toBeNull 匹配器', () => {
// toBeNull
const a = null
expect(a).toBeNull()
})
複製代碼
toBeUndefined:只匹配 undefined
test('toBeUndefined 匹配器', () => {
const a = undefined
expect(a).toBeUndefined()
})
複製代碼
toBeDefined:與 toBeUndefined 相反,這裏匹配 null 是通過的
test('toBeDefined 匹配器', () => {
const a = null
expect(a).toBeDefined()
})
複製代碼
toBeTruthy:匹配任何 if 語句爲 true
test('toBeTruthy 匹配器', () => {
const a = 1
expect(a).toBeTruthy()
})
複製代碼
toBeFalsy:匹配任何 if 語句爲 false
test('toBeFalsy 匹配器', () => {
const a = 0
expect(a).toBeFalsy()
})
複製代碼
not:取反
test('not 匹配器', () => {
const a = 1
// 以下兩個匹配器是一樣的
expect(a).not.toBeFalsy()
expect(a).toBeTruthy()
})
複製代碼
數字
toBeGreaterThan:大於
test('toBeGreaterThan', () => {
const count = 10
expect(count).toBeGreaterThan(9)
})
複製代碼
toBeLessThan:小於
test('toBeLessThan', () => {
const count = 10
expect(count).toBeLessThan(12)
})
複製代碼
toBeGreaterThanOrEqual:大於等於
test('toBeGreaterThanOrEqual', () => {
const count = 10
expect(count).toBeGreaterThanOrEqual(10) // 大於等於 10
})
複製代碼
toBeLessThanOrEqual:小於等於
test('toBeLessThanOrEqual', () => {
const count = 10
expect(count).toBeLessThanOrEqual(10) // 小於等於 10
})
複製代碼
toBeCloseTo:計算浮點數
test('toBeCloseTo', () => {
const firstNumber = 0.1
const secondNumber = 0.2
expect(firstNumber + secondNumber).toBeCloseTo(0.3) // 計算浮點數
})
複製代碼
字符串
toMatch:匹配某個特定項字符串,支持正則
test('toMatch', () => {
const str = 'http://www.zsh.com'
expect(str).toMatch('zsh')
expect(str).toMatch(/zsh/)
})
複製代碼
數組
toContain:匹配是否包含某個特定項
test('toContain', () => {
const arr = ['z', 's', 'h']
const data = new Set(arr)
expect(data).toContain('z')
})
複製代碼
異常
toThrow
const throwNewErrorFunc = () => {
throw new Error('this is a new error')
}
test('toThrow', () => {
// 拋出的異常也要一樣纔可以通過,也可以寫正則表達式
expect(throwNewErrorFunc).toThrow('this is a new error')
})
複製代碼
測試異步代碼
假設請求函數如下
const fethUserInfo = fetch('http://xxxx')
複製代碼
測試異步代碼有好幾種方式,我就推薦一種我認爲比較常用的方式
// fetchData.test.js
// 測試promise成功需要加.resolves方法
test('the data is peanut butter', async () => {
await expect(fethUserInfo()).resolves.toBe('peanut butter');
});
// 測試promise成功需要加.rejects方法
test('the fetch fails with an error', async () => {
await expect(fethUserInfo()).rejects.toMatch('error');
});
複製代碼
作用域
jest 提供一個 describle 函數來分離各個 test 測試用例,就是把相關的代碼放到一類分組中,這麼簡單,看個例子就懂了。
// 分組一
describe('Test xxFunction', () => {
test('Test default return zero', () => {
expect(xxFunction()).toBe(0)
})
// ...其它test
})
// 分組二
describe('Test xxFunction2', () => {
test('Pass 3 can return 9', () => {
expect(xxFunction2(3)).toBe(9)
})
// ...其它test
})
複製代碼
鉤子函數
jest 中有 4 個鉤子函數
-
beforeAll:所有測試之前執行
-
afterAll:所有測試執行完之後
-
beforeEach:每個測試實例之前執行
-
afterEach:每個測試實例完成之後執行
我們舉例來說明爲什麼需要他們。
在 index.js
中寫入一些待測試方法
export default class compute {
constructor() {
this.number = 0
}
addOne() {
this.number += 1
}
addTwo() {
this.number += 2
}
minusOne() {
this.number -= 1
}
minusTwo() {
this.number -= 2
}
}
複製代碼
假如我們要 在 index.test.js
中寫測試實例
import compute from './index'
const Compute = new compute()
test('測試 addOne', () => {
Compute.addOne()
expect(Compute.number).toBe(1)
})
test('測試 minusOne', () => {
Compute.minusOne()
expect(Compute.number).toBe(0)
})
複製代碼
-
這裏兩個測試實例相互之間影響了,共用了一個 computet 實例,我們可以將
const Compute = new compute()
放在 beforEach 裏面就可以解決了,每次測試實例之前先重新 new compute -
同理,你想在每個 test 測試完畢後單獨運行什麼可以放入到
afterEach
中
我們接着看一下什麼情況下使用beforeAll
,假如我們測試數據庫數據是否保存正確
-
我們在測試最開始, 也就是
beforeAll
生命週期裏, 新增 1 條數據到數據庫裏 -
測試完後,也就是
afterAll
週期裏, 刪除之前添加的數據 -
最後利用全局作用域
afterAll
確認數據庫是否還原成初始狀態
這裏說到
// 模擬數據庫
const userDB = [
{ id: 1, name: '小明' },
{ id: 2, name: '小花' },
]
// 新增數據
const insertTestData = data => {
// userDB,push數據
}
// 刪除數據
const deleteTestData = id => {
// userDB,delete數據
}
// 全部測試完
afterAll(() => {
console.log(userDB)
})
describe('Test about user data', () => {
beforeAll(() => {
insertTestData({ id: 99, name: 'CS' })
})
afterAll(() => {
deleteTestData(99)
})
})
複製代碼
jest 裏的 Mock
爲什麼要使用 Mock 函數?
在項目中,經常會碰見 A 模塊掉 B 模塊的方法。並且,在單元測試中,我們可能並不需要關心內部調用的方法的執行過程和結果,只想知道它是否被正確調用即可,甚至會指定該函數的返回值。此時,就需要 mock 函數了。
Mock 函數提供的以下三種特性,在我們寫測試代碼時十分有用:
-
捕獲函數調用情況
-
設置函數返回值
-
改變函數的內部實現
jest.fn()
jest.fn()
是創建 Mock 函數最常用的方式。
test('測試jest.fn()', () => {
let mockFn = jest.fn();
let result = mockFn(1);
// 斷言mockFn被調用
expect(mockFn).toBeCalled();
// 斷言mockFn被調用了一次
expect(mockFn).toBeCalledTimes(1);
// 斷言mockFn傳入的參數爲1
expect(mockFn).toHaveBeenCalledWith(1);
})
複製代碼
jest.fn()
所創建的 Mock 函數還可以設置返回值,定義內部實現或返回Promise
對象。
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執行後返回100
expect(mockFn(10, 10)).toBe(100);
})
test('測試jest.fn()返回Promise', async () => {
let mockFn = jest.fn().mockResolvedValue('default');
let result = await mockFn();
// 斷言mockFn通過await關鍵字執行後返回值爲default
expect(result).toBe('default');
// 斷言mockFn調用後返回的是Promise對象
expect(Object.prototype.toString.call(mockFn())).toBe("[object Promise]");
})
複製代碼
2. jest.mock()
fetch.js
文件夾中封裝的請求方法可能我們在其他模塊被調用的時候,並不需要進行實際的請求(請求方法已經通過單測或需要該方法返回非真實數據)。此時,使用jest.mock()
去 mock 整個模塊是十分有必要的。
下面我們在src/fetch.js
的同級目錄下創建一個src/events.js
。
import fetch from './fetch';
export default {
async getPostList() {
return fetch.fetchPostsList(data => {
console.log('fetchPostsList be called!');
// do something
});
}
}
複製代碼
import events from '../src/events';
import fetch from '../src/fetch';
jest.mock('../src/fetch.js');
test('mock 整個 fetch.js模塊', async () => {
expect.assertions(2);
await events.getPostList();
expect(fetch.fetchPostsList).toHaveBeenCalled();
expect(fetch.fetchPostsList).toHaveBeenCalledTimes(1);
});
複製代碼
在測試代碼中我們使用了jest.mock('../src/fetch.js')
去 mock 整個fetch.js
模塊。如果註釋掉這行代碼,執行測試腳本時會出現以下報錯信息
從這個報錯中,我們可以總結出一個重要的結論:
在 jest 中如果想捕獲函數的調用情況,則該函數必須被 mock 或者 spy!
3. jest.spyOn()
jest.spyOn()
方法同樣創建一個 mock 函數,但是該 mock 函數不僅能夠捕獲函數的調用情況,還可以正常的執行被 spy 的函數。實際上,jest.spyOn()
是jest.fn()
的語法糖,它創建了一個和被 spy 的函數具有相同內部代碼的 mock 函數。
上圖是之前jest.mock()
的示例代碼中的正確執行結果的截圖,從 shell 腳本中可以看到console.log('fetchPostsList be called!');
這行代碼並沒有在 shell 中被打印,這是因爲通過jest.mock()
後,模塊內的方法是不會被 jest 所實際執行的。這時我們就需要使用jest.spyOn()
。
// functions.test.js
import events from '../src/events';
import fetch from '../src/fetch';
test('使用jest.spyOn()監控fetch.fetchPostsList被正常調用', async() => {
expect.assertions(2);
const spyFn = jest.spyOn(fetch, 'fetchPostsList');
await events.getPostList();
expect(spyFn).toHaveBeenCalled();
expect(spyFn).toHaveBeenCalledTimes(1);
})
複製代碼
執行npm run test
後,可以看到 shell 中的打印信息,說明通過jest.spyOn()
,fetchPostsList
被正常的執行了。
快照
快照就是對你對比的數據會存一份副本,啥意思呢,我們舉個例子:
這是index.js
export const data2 = () => {
return {
name: 'zhangsan',
age: 26,
time: new Date()
}
}
複製代碼
在 index.test.js
中寫入一些測試實例
import { data2 } from "./index"
it('測試快照 data2', () => {
expect(data2()).toMatchSnapshot({
name: 'zhangsan',
age: 26,
time: expect.any(Date) //用於聲明是個時間類型,否則時間會一直改變,快照不通過
})
})
複製代碼
-
toMatchSnapshot
會將參數將快照進行匹配 -
expect.any(Date)
用於匹配一個時間類型
執行npm run test
會生成一個__snapshots__
文件夾,裏面是生成的快照,當你修改一下測試代碼時,會提示你,快照不匹配。如果你確定你需要修改,按 u 鍵,即可更新快照。這用於 UI 組件的測試非常有用。
React 的 BDD 單測
接下來我們看下 react 代碼如何進行測試,用一個很小的例子來說明。
案例中引入了 enzyme。Enzyme 來自 airbnb 公司,是一個用於 React 的 JavaScript 測試工具,方便你判斷、操縱和歷遍 React Components 輸出。
我們達成的目的是檢測:
-
用戶進入首頁,看到兩個按鈕,分別是 counter1 和 counter2
-
點擊 counter1,就能看到兩個按鈕的文字部分分別是 "counter1" 和 "counter2"
react 代碼如下
import React from 'react';
function Counter(){
return (
<ul>
<li>
<button id='counter1' className='button1'>counter1</button>
</li>
<li>
<button id='counter2' className='button2'>counter2</button>
</li>
</ul>
)
}
複製代碼
單測的文件:
import Counter from xx;
import { mount } from 'enzyme';
describle('測試APP',() => {
test('用戶進入首頁,看到兩個按鈕,分別是counter1和counter2,並且按鈕文字也是counter1和counter2',()=>{
const wrapper = mount(<Counter />);
const button = wrapper.find('button');
except(button).toHaveLength(2);
except(button.at(0).text()).toBe('counter1');
except(button.at(1).text()).toBe('counter2');
})
})
複製代碼
Jest | 測試設置分類(describe)及作用域 [1]
jest 入門單元測試 [2]
關於本文
作者:孟祥_成都
https://juejin.cn/post/7092188990471667749
最後
歡迎關注【前端瓶子君】✿✿ヽ (°▽°) ノ✿
回覆「算法」,加入前端編程源碼算法羣!領取最新最熱的前端算法小書、面試小書以及海量簡歷模板,期待與你共進步!
回覆「交流」,吹吹水、聊聊技術、吐吐槽!
回覆「閱讀」,每日刷刷高質量好文!
如果這篇文章對你有幫助,「在看」是最大的支持
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/PZsYCKfij8MdxVxPq9B5Kg