React躬行記(14)——測試框架
測試不僅可以發現和預防問題,還能降低風險、減少企業損失。在React中,湧現了多種測試框架,本節會對其中的Jest和Enzyme做詳細的講解。
一、Jest
Jest是由Facebook開源的一個測試框架,可無縫相容React專案,專注簡單,推崇零配置,開箱即用的宗旨,用於邏輯和元件的單元測試。它的語法和斷言與Jasmine類似,並且還集成了快照測試、Mock、覆蓋率報告等功能,支援多程序並行執行測試,在內部使用JSDOM操作DOM,JSDOM是一種模擬的DOM環境,其行為類似於常規瀏覽器,可用來與使用者互動、在節點上派發事件等。
1)執行
為了便於執行Jest,本文使用Create React App建立專案,命令如下所示。
npx create-react-app my-app
只要把測試檔案放置在__tests__目錄內,或將它們的名稱新增.test.js或.spec.js字尾,並儲存在專案的src目錄中的任何深度,就能被Jest檢測到。當執行下面的命令時,可得到相關的測試結果。
npm test
預設情況下,Jest每次只執行與本次更改的檔案相關的測試用例。
2)建立測試
如果要建立測試用例(Test Case),那麼需要使用test()或it()函式,其第一個引數是測試名稱,第二個引數是包含測試程式碼的回撥函式,如下所示。
test("two plus two is four", () => { expect(2 + 2).toBe(4); });
expect()函式用於斷言,它能接收一個實際值,並將其作為結果與匹配器中的期望值做比較。如果匹配失敗,那麼就會在控制檯輸出相應的錯誤提示。
describe()函式可將測試用例進行邏輯分組,其第一個引數可定義分組的名稱,如下所示。
describe("my test case", () => { test("one plus one is two", () => { expect(1 + 1).toBe(2); }); test("two plus two is four", () => { expect(2 + 2).toBe(4); }); });
3)匹配器
通過匹配器(Matcher)可以各種方式來測試程式碼,例如之前示例中的toBe()就是一個匹配器,它使用Object.is()來測試精確匹配,如果要檢查物件是否相等,可改用toEqual(),如下所示。
test("object assignment", () => { const data = { name: "strick" }; data["age"] = 28; expect(data).toEqual({ name: "strick", age: 28 }); });
其它常用的匹配器還有區分undefined、null和布林值、比較數字、匹配字串、檢查陣列或可迭代物件是否包含某個特定項、測試丟擲的錯誤等功能。
所有的匹配器都可以通過.not取反,例如驗證toBeUndefined()不能匹配null,如下所示。
test("null is not undefined", () => { expect(null).not.toBeUndefined(); });
4)非同步測試
Jest提供了多種方式來測試非同步程式碼,包括回撥函式、Promise和Async/Await,接下來會逐個講解用法。
(1)預設情況下,Jest測試一旦執行到末尾就會完成,例如有一個check()函式(如下所示),它能接收一個回撥函式,一旦check()執行結束,此測試就會在沒有執行回撥函式前結束。
function check(func) { const success = true; func(success); } test("the data is truth", () => { function callback(data) { expect(data).toBeTruthy(); } check(callback); });
若要解決此問題,可為test()的回撥函式傳遞一個名為done的函式引數,Jest會等done()回撥函式執行完後,再結束測試,如下所示。
test("the data is truth", done => { function callback(data) { expect(data).toBeTruthy(); done(); } check(callback); });
(2)當非同步程式碼返回Promise物件時,Jest會等待其狀態的變化。如果狀態變為已完成,那麼得使用then()方法;如果狀態變為已拒絕,那麼得使用catch()方法,如下所示。
//狀態為已完成 function checkResolve() { return new Promise((resolve, reject) => { resolve(true); }); } test("the data is truth", () => { return checkResolve().then(data => { expect(data).toBeTruthy(); }); }); //狀態為已拒絕 function checkReject() { return new Promise((resolve, reject) => { reject(false); }); } test("the data is falsity", () => { return checkReject().catch(data => { expect(data).toBeFalsy(); }); });
注意,要將Promise物件作為test()的回撥函式的返回值,以免測試提前完成,導致沒有進行方法鏈中的斷言。
在expect語句中也可以使用.resolves或.rejects兩種匹配器來處理Promise的兩種狀態,如下所示,語法更為簡潔。
test("the data is truth", () => { expect(checkResolve()).resolves.toBeTruthy(); }); test("the data is falsity", () => { expect(checkReject()).rejects.toBeFalsy(); });
(3)在測試中使用async和await兩個關鍵字,也可以匹配Promise物件,例如斷言checkResolve()的處理結果,如下所示。
test("the data is truth", async () => { const data = await checkResolve(); expect(data).toBeTruthy(); });
它們也能用來測試已拒絕狀態的Promise,如下所示,其中assertions()用於驗證在測試中是否執行了指定數量的斷言。
function checkError() { return new Promise((resolve, reject) => { reject(); }).catch(() => { throw "error"; }); } test("the check fails with an error", async () => { expect.assertions(1); try { await checkError(); } catch (e) { expect(e).toMatch("error"); } });
aysnc和awiat還可以與.resolves或.rejects結合使用,如下所示。
test("the data is truth", async () => { await expect(checkResolve()).resolves.toBeTruthy(); }); test("the check fails with an error", async () => { await expect(checkError()).rejects.toMatch("error"); });
5)輔助函式
有時候,在執行測試前需要做些準備工作,而在執行測試之後又需要做些整理工作,Jest提供了四個相關的輔助函式來處理這兩類工作,如下所列。
(1)beforeAll()和afterAll()會在所有測試用例之前和之後執行一次。
(2)beforeEach()和afterEach()會在每個測試用例之前和之後執行,並且可以像非同步測試那樣處理非同步程式碼。
假設在四個輔助函式中輸出各自的函式名稱,並且有兩個測試用例,如下程式碼所示。
beforeAll(() => { console.log("beforeAll"); }); afterAll(() => { console.log("afterAll"); }); beforeEach(() => { console.log("beforeEach"); }); afterEach(() => { console.log("afterEach"); }); test("first", () => { expect(2).toBeGreaterThan(1); }); test("second", () => { expect(2).toBeLessThan(3); });
每次執行測試,在控制檯將依次打印出“beforeAll”,兩對“beforeEach”和“afterEach”,“afterAll”。
當通過describe()對測試用例進行分組時(如下所示),外部的beforeEach()和afterEach()會優先執行。
describe("scoped", () => { beforeEach(() => console.log("inner beforeEach")); afterEach(() => console.log("inner afterEach")); test("third", () => { expect([1, 2]).toContain(1); }); });
6)Mock
Jest內建了Mock函式,可用於擦除函式的實際實現來測試程式碼之間的連線,捕獲函式的呼叫和引數、配置其返回值等。
假設要測試一個自定義的forEach()函式的內部實現,那麼可以使用jest.fn()建立一個Mock函式,然後通過檢查它的mock屬性來確保回撥函式是否在按預期呼叫,如下所示。
function forEach(items, callback) { for (let index = 0; index < items.length; index++) { callback(items[index]); } } test("forEach", () => { const mockFunc = jest.fn(x => 42 + x); forEach([0, 1], mockFunc); expect(mockFunc.mock.calls.length).toBe(2); //此Mock函式被呼叫了兩次 expect(mockFunc.mock.calls[0][0]).toBe(0); //第一次呼叫函式時的第一個引數是0 expect(mockFunc.mock.calls[1][0]).toBe(1); //第二次呼叫函式時的第一個引數是1 expect(mockFunc.mock.results[0].value).toBe(42); //第一次函式呼叫的返回值是42 });
每個Mock函式都會包含一個特殊的mock屬性,記錄了函式如何被呼叫、呼叫時的返回值等資訊,通過該屬性還能追蹤每次呼叫時的this的值。如果要用Mock函式注入返回值,那麼可以像下面這樣鏈式的新增,首次呼叫返回10,第二次呼叫返回“x”,接下來的呼叫都返回true。其中mockName()方法可為Mock函式命名,該名稱將在輸出的日誌中顯示,可替換掉預設的“jest.fn()”。
const myMock = jest.fn().mockName("returnValue"); myMock .mockReturnValueOnce(10) .mockReturnValueOnce("x") .mockReturnValue(true); console.log(myMock(), myMock(), myMock(), myMock()); //10, 'x', true, true
Mock函式還可以模擬模組,例如攔截axios請求得到的資料,如下程式碼所示,為.get提供了一個mockResolvedValue()方法,它會返回用於測試的假資料。
import axios from "axios"; jest.mock("axios"); class Users { static all() { return axios.get("./users.json").then(resp => resp.data); } } test("should fetch users", () => { const users = [{ name: "strick" }]; const resp = { data: users }; axios.get.mockResolvedValue(resp); return Users.all().then(data => expect(data).toEqual(users)); });
原生的定時器函式測試起來並不方便,通過jest.useFakeTimers()可以模擬定時器函式,如下所示。
function timerGame() { setTimeout(() => { console.log("start"); }, 1000); } jest.useFakeTimers(); test("setTimeout", () => { timerGame(); expect(setTimeout).toHaveBeenCalledTimes(1); //呼叫了1次 expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000); //1秒後執行回撥 });
Jest模擬出的定時器函式還有快進到正確的時間點、執行當前正在等待的定時器等功能。
7)快照測試
Jest提供的快照測試(Spapshot Testing)是一種高效的UI測試,它會將React元件序列化成純文字(即快照)並儲存在硬碟中,每次測試就把當前生成的快照與儲存的快照進行對比,接下來用一個例子來介紹快照測試的用法。
首先建立一個Link元件,它會渲染出一條包含onMouseEnter事件的連結,當滑鼠移動到這條連結時,會改變它的class屬性。
import React from "react"; const STATUS = { HOVERED: "hovered", NORMAL: "normal" }; export default class Link extends React.Component { constructor(props) { super(props); this._onMouseEnter = this._onMouseEnter.bind(this); this.state = { class: STATUS.NORMAL }; } _onMouseEnter() { this.setState({ class: STATUS.HOVERED }); } render() { return ( <a href="#" className={this.state.class} onMouseEnter={this._onMouseEnter} > {this.props.children} </a> ); } }
然後建立測試檔案spapshot.test.js,在其內部,除了要引入Link元件之外,還得引入react-test-renderer,它不依賴瀏覽器和JSDOM,可將React元件渲染成JavaScript物件(即快照)。
import React from "react"; import Link from "./Link"; import renderer from "react-test-renderer"; test("Link changes the class when hovered", () => { const component = renderer.create(<Link>Strick</Link>); let tree = component.toJSON(); expect(tree).toMatchSnapshot(); tree.props.onMouseEnter(); //觸發事件 tree = component.toJSON(); //重新渲染 expect(tree).toMatchSnapshot(); });
在第一次執行測試時,會自動建立__snapshots__目錄,放置對應的快照檔案spapshot.test.js.snap,其內容如下所示,包含兩張快照,第二張是觸發onMouseEnter事件後生成的。
exports[`Link changes the class when hovered 1`] = ` <a className="normal" href="#" onMouseEnter={[Function]} > Strick </a> `; exports[`Link changes the class when hovered 2`] = ` <a className="hovered" href="#" onMouseEnter={[Function]} > Strick </a> `;
如果要重新整理儲存的快照,除了手動刪除之外,還可以通過jest -u命令實現。
二、Enzyme
Enzyme是一款用於React元件的測試框架,可處理渲染出的DOM結構,開放的API類似於jQuery的語法,提供了三種不同的方式來測試元件:淺層渲染(Shallow Rendering)、完全渲染(Full Rendering)和靜態渲染(Static Rendering)。從Enzyme 3開始,在安裝Enzyme的同時,還需要安裝與React版本相對應的介面卡,命令如下所示。
npm install --save enzyme enzyme-adapter-react-16
1)淺層渲染
獨立於DOM的淺層渲染只會渲染React元件的第一層,它會忽略子元件的行為,也就沒必要渲染子元件了,這提供了更好的隔離性。不過淺層渲染也有它侷限性,即不支援Refs。
以上一節中的Link元件為例,在進行Enzyme之前,需要先通過configure()函式配置介面卡,然後才能通過shallow()函式淺渲染Link元件,如下所示。
import React from "react"; import { shallow, configure } from "enzyme"; import Adapter from "enzyme-adapter-react-16"; import Link from "../component/Form/Link"; configure({ adapter: new Adapter() }); test("Link changes the class after mouseenter", () => { const wrapper = shallow(<Link>Strick</Link>), a = wrapper.find("a"); expect(wrapper.text()).toEqual("Strick"); a.simulate("mouseenter"); //觸發事件 expect(a.prop("className")).toEqual("normal"); //匹配樣式 });
wrapper是一個虛擬的DOM物件,它包含多個操作DOM的方法,例如find()可根據選擇器找到指定的節點,simulate()可觸發當前節點的事件。
2)完全渲染
mount()函式會完全渲染接收的元件,即它的子元件也會被渲染。完全渲染依賴JSDOM,當多個測試處理同一個DOM時,可能會相互影響,因此在測試結束後需要使用unmount()方法解除安裝元件。
3)靜態渲染
render()函式會靜態渲染元件,也就是將它渲染成HTML字串,再通過Cheerio庫解析該HTML結構。Cheerio類似於JSDOM,但更輕量,可像jQuery那樣操作字串。
&n