一.是什麼?
Jest是Facebook開源的一個前端測試框架,自動集成了斷言、JSdom、覆蓋率報告等,內部也是使用了jasmine作爲基礎,在其上進行了封裝。主要用於React和React Native的單元測試,已被集成在create-react-app中。
二.特點
- 速度快:支持並行執行,Jest 爲每一個單元測試文件創造一個獨立的運行環境。Jest 可以啓動多個進程同時運行不同的文件,充分利用多核CPU。單進程 100 秒才完成的測試執行過程,8 核只需要 12.5 秒。並且可以通過test.only這種語法只運行特定的測試用例。
- 易配置:雖然號稱0配置,但在真實的項目中,還是要根據具體需求,進行配置。
- IDE整合:配合vscode中的jest插件,編寫完測試用例後,IDE自動運行測試用例。
- Snapshot:Jest能夠對React組件樹進行序列化,生成對應的字符串快照,通過比較字符串提供高性能的UI檢測。
- 多項目並行:可以同時跑React和node的測試用例。
- 覆蓋率:內置Istanbul,測試代碼覆蓋率,並生成對應的報告
- Mock系統:Jest實現了一個強大的Mock系統,支持自動和手動mock。
三.實現原理
通過對比預期和實際結果進行測試。
假如我們有一個這樣的函數需要測試,如下:
function add(a, b) {
return a + b
}
是的,只是計算傳入參數之和,並返回。
測試代碼如下(模擬Jest內部實現)
function expect(result) {
return {
toBe: function(actual) {
if(result !== actual) {
throw new Error(`預期值和實際值不相等 預期${actual} 實際結果卻是${result}`)
}
}
}
}
function test(desc, fn) {
try {
fn();
console.log(`${desc} 通過測試`)
}catch(e) {
console.log(`${desc} 沒有通過測試 ${e}`)
}
}
test("測試加法 1 + 1 是否等於 2", () => {
expect(add(1, 1)).toBe(2);
})
四.生命週期函數
- beforeAll(fn):所有測試用例執行之前執行的方法。
- beforeEach(fn):每個測試用例執行之前執行的方法。
- afterEach(fn):每個測試用例執行之後執行的方法。
- afterAll(fn):所有測試用例執行之後執行的方法。
全局和describe都可以有這四個生命週期函數。
一個demo說明他們的執行順序:
export default class Counter {
constructor() {
this.number = 0;
}
addOne() {
this.number += 1;
}
minusOne() {
this.number -= 1;
}
}
// 測試代碼
import Counter from './Counter';
describe("測試 Counter", () => {
let counter = null;
console.log("第一層describe");
beforeAll(() => {
console.log("beforeAll");
})
beforeEach(() => {
console.log("beforeEach");
counter = new Counter();
})
afterEach(() => {
console.log("afterEach");
})
afterAll(() => {
console.log("afterAll");
})
describe("測試增加相關的代碼", () => {
console.log("第二層describe");
beforeAll(() => {
console.log("增加相關 -- beforeAll");
})
beforeEach(() => {
console.log("增加相關 -- beforeEach");
})
afterEach(() => {
console.log("增加相關 -- afterEach");
})
afterAll(() => {
console.log("增加相關 -- afterAll");
})
test("測試 Counter 中的 addOne 方法", () => {
console.log("測試 Counter 中的 addOne 方法");
counter.addOne();
expect(counter.number).toBe(1);
})
})
})
輸出結果:
執行說明:外層的before函數優先級最高,並且All優先於Each。而after則是內層的優先級最高,並且Each優先於All。
請注意:我在每個describe的最開始,都打印了一句。他們總是被最先打印出來。這說明,只要沒在生命週期函數中,每個describe塊中的其餘代碼總是被優先執行的。
生命週期函數有什麼用哩?
可以做一些初始化工作,我在上面的beforeEach中實例化了一個counter,這樣每個測試用例之間的counter就不會相互影響。
五.異步代碼的測試
- 回調函數類型
import axios from "axios";
export const c = (fn) => {
axios.get("http://www.dell-lee.com/react/api/demo.json").then(res => {
fn(res.data);
})
}
向某一接口發起請求,請求成功後,用返回的數據作爲參數調用回調函數。
test("fecthData1 返回結果爲 { success: true }", (done) => {
fetchData1((data) => {
expect(data).toEqual({
success: true
})
done();
})
})
這種形式的異步函數測試,一定要加done參數,因爲fetchData1函數時異步的,如果不加,回調函數還沒有被調用,test函數就執行完成了,測試就會直接通過了。所以,通過done參數這種形式,只有done函數被調用了,纔會認爲test執行完成。
- Promise類型
export const fetchData2 = () => {
return axios.get("http://www.dell-lee.com/react/api/demo.json")
}
test("fecthData2 返回結果爲 { success: true }", () => {
return fetchData2().then(res => {
expect(res.data).toEqual({
success: true
})
})
})
測試這種返回Promise類型時,一定要在前面加上return。這樣才能讓jest明白這是個異步過程,纔會去等待異步執行結果。
- async和await類型
還是上面的那個fetchData2函數
test("fecthData2 返回結果爲 { success: true }", async () => {
const res = await fetchData2();
expect(res.data).toEqual({
success: true
})
})
個人認爲,這種測試代碼最好理解,比較推薦使用這種形式。
最後,有時候,我們就是想去測試404。該怎麼做哩?
export const fetchData3 = () => {
return axios.get("http://www.dell-lee.com/react/api/demo1.json")
}
上面這個接口不存在,所以會返回404。
test("fecthData3 返回結果爲 404", () => {
expect.assertions(1);
return fetchData3().catch(e => {
expect(e.toString().indexOf("404") > -1).toBe(true);
})
})
使用expect.assertions(1)
,必須要一個斷言被執行,否則測試失敗。這樣當請求接口出錯時,就會執行catch中的代碼,從而保證有一個expect被執行了。
六.定時器的測試
export default (callback) => {
setTimeout(() => {
callback();
}, 3000)
}
import timer from './timer';
test('定時器測試', (done) => {
timer(() => {
expect(1).toBe(1);
done();
})
})
這是一種最簡單的定時器測試代碼,但存在很大的問題。如果設置的定時時間是幾百秒哩?難道我們要等待幾百秒嗎?
如何解這個問題哩?
import timer from './timer';
jest.useFakeTimers();
test("timer 測試", () => {
const fn = jest.fn();
// 這樣timer還是會等待定時器設置的時長再執行
timer(fn);
// 所以必須配合下面的代碼
// 這種代碼的意思是讓timer立刻執行,我們就不必等待了
jest.runAllTimers();
expect(fn).toHaveBeenCalledTimes(1);
})
有時候,我們會有這種需求,定時器的嵌套。
export default (callback) => {
setTimeout(() => {
callback();
setTimeout(() => {
callback();
}, 2000)
}, 1000)
}
我們只想測試第一層的timer,如果使用runAllTimers,內層的timer也會直接運行。如何解決?
import timer from './timer';
jest.useFakeTimers();
test("timer 測試", () => {
const fn = jest.fn();
// 這樣timer還是會等待定時器設置的時長再執行
timer(fn);
// 所以必須配合下面的代碼
// 這種代碼的意思是讓timer立刻執行,我們就不必等待了,
// 只運行處於隊列中的timer,沒有被添加到隊列中的timer不會被執行
jest.runOnlyPendingTimers();
expect(fn).toHaveBeenCalledTimes(1);
})
上面這種方式理解難度較大,所以可以使用時間快進的函數。
import timer from './timer';
jest.useFakeTimers();
test("timer 測試", () => {
const fn = jest.fn();
timer(fn);
// advanceTimersByTime 快進時間
jest.advanceTimersByTime(1000);
expect(fn).toHaveBeenCalledTimes(1);
// 時間快進是在上一次的基礎上,進行的
// 所以說,這個測試用例的時間快進可能會影響其餘的測試用例,
// 所以可以在鉤子函數中執行 jest.useFakeTimers() 從而初始化定時器
jest.advanceTimersByTime(2000);
expect(fn).toHaveBeenCalledTimes(2);
})
七.Mock
- mock函數,捕獲函數的調用和返回結果,以及this指向,和調用順序。
import axios from 'axios';
export const runCallback = (callback) => {
callback();
}
test("測試 runCallback", () => {
// 只要能證明func被調用過,那麼runCallback函數就被正確的調用啦。
const func = jset.fn();
runCallback(func);
expect(func).toBeCalled();
})
- mock函數,自由的設置返回結果
test("測試 runCallback", () => {
const func = jest.fn(() => {
return "456"
});
func.mockReturnValueOnce("afeng");
/* func.mockImplementation(() => {
console.log(1);
return "yafeng"
}) */
runCallback(func);
runCallback(func);
console.log(func.mock);
expect(func.mock.calls.length).toBe(2);
})
上面測試中,我們打印了func.mock,其上存在以下屬性。
calls:函數的調用次數,以及每次調用傳入的參數。
instances:函數調用時,this的指向。
invocationCallOrder:函數調用的順序,
results:函數調用的返回結果。
- mock函數,可以改變函數的內部實現。
export const getData = () => {
return axios.get('/api').then(res => res.data)
}
假如我們有以上異步代碼需要測試,在真實的項目中,我們不會真正的去發送這種ajax請求。假設我們項目中有1000個這樣的異步請求,每一個請求的時間是3s,那麼我們運行一次測試,需要耗費大量的時間。所以在前端測試這一塊,我們只需要確認請求發送出去了就可以了,至於,後端接口是否可以正常返回,這是後端測試需要做的。
import axios from 'axios';
// mock axios,不會發送真實的請求,會使用模擬的數據
jest.mock("axios");
test("測試 getData", async () => {
// 3. mock函數,改變函數的內部實現,本來是要異步的發請求,現在改爲同步的返回數據
axios.get.mockResolvedValue({data: "hello"});
await getData().then((data) => {
expect(data).toBe("hello")
})
})
上面,我們是通過mock,axios的get請求來進行同步的準備數據的。其實我們也可以mock,fetchData函數。如何做呢?
在項目的根目錄下新建__mocks__文件夾,建一個與需要mock的同名的js文件。
比如我們需要mock下面這個fetchData函數的實現。
import axios from 'axios';
export const fetchData = () => {
return axios.get("/").then(res => res.data);
}
export const getNumber = () => {
return "456"
}
在__mocks__中新建一個js文件。代碼如下:
export const fetchData = () => {
return new Promise((resolved, reject) => {
resolved(
"(function() {return '123'})()"
)
})
}
測試代碼如下:
jest.mock('./demo');
import { fetchData } from './demo';
const { getNumber } = jest.requireActual('./demo');
test("測試fetchData", () => {
return fetchData().then(data => {
expect(eval(data)).toBe("123");
})
})
test("測試fetchData", () => {
expect(getNumber()).toBe("456")
})
由於我們使用了jest.mock();所以當import時,引用的將是mock的fetchData函數。
八.類的測試
我們有這樣的一個類
export default class Util {
a() {
// 邏輯異常複雜
}
b() {
// 邏輯異常複雜
}
}
關於類的測試,我們會有一個專門的測試文件,現在的需求是這樣的,我們有一個模塊,導入了這個類,並且調用了a,b兩個方法。
所以,當我們測試這個模塊時,我們不想真正的調用這兩個方法。因此可以使用Mock。
import Util from './util';
const demoFunction = (a, b) => {
const util = new Util;
util.a(a);
util.b(b);
}
export default demoFunction;
jest.mock("./util.js");
// jest.mock 發現 util 是一個類,會自動的把類的構造函數和方法變成 jest.fn()
// 背後原理如下:
// const Util = jest.fn();
// Util.a = jest.fn();
// Util.b = jest.fn();
// 由於類的方法都是一個 jest.fn() 所以我們可以對類的方法執行做追溯
import demoFunction from './demo';
import Util from './util';
test("測試 demoFunction", () => {
// 要想測試demoFunction成功執行了,只需驗證他調用了Util類創建實例,並且調用a,b 方法.
// 但由於Util中的方法都異常複雜,全部執行,很耗性能。所以我們需要去模擬Util類
demoFunction();
expect(Util).toHaveBeenCalled();
expect(Util.mock.instances[0].a).toHaveBeenCalled();
expect(Util.mock.instances[0].b).toHaveBeenCalled();
})