大家好,我是若川。持續組織了6個月源碼共讀活動,感興趣的可以點此加我微信 ruochuan02?參與,每周大家一起學習200行左右的源碼,共同進步。同時極力推薦訂閱我寫的《學習源碼整體架構系列》?包含20余篇源碼文章。歷史面試系列
1、背景
以前還是學生的時候,有學習一門與測試相關的課程。那個時候,覺得測試就是寫 test case,寫斷言,跑測試,以及查看 test case 的 coverage。整個流程和寫法也不是特別難,所以就理所當然地覺得,寫測試也不是特別難。
加上之前實際的工作中,也沒有太多的寫測試的經歷,所以當自己需要對組件庫補充單元測試的時候,發現并不能照葫蘆畫瓢來寫單測。一時不知道該如何下手,也不知道如何編寫有效的單測,人有點懵,于是就比較粗略地研究了一下前端組件單測。
1.1 單測的目的
在頻繁的需求變動中可控地保障代碼變動的影響范圍
提升代碼質量和開發測試效率
保證代碼的整潔清晰
......
總之單測是一個保證產品質量的非常強大的手段。
1.2 測試框架和 UI 組件測試工具
而說起前端的測試框架和工具,比較主流的 JavaScript 測試框架有 Jest、Jasmine、Mocha 等等,并且還有一些 UI 組件測試工具,比如 testing-libraray,enzyme 等等。
測試框架和 UI 組件測試工具之間并不是相互依賴、非此即彼的,而是可以根據不同工具的性質做不同的搭配。目前騰訊課堂基于 Tdesign 開發的素材庫組件的單測,就是使用 Jest + React Testing Library 來完成。
1.3 組件單測須知
在開始進行組件單測的時候,有幾個因素我們需要考慮:
組件是否按照既定的條件 / 邏輯進行渲染
組件的事件回調是否正確
異步接口如何校驗
異步執行完畢后的操作如何校驗
......
當然不止這些列舉出來的,根據不同的業務場景,我們考慮的因素需要更全面更細致。
2、Jest 的使用
Jest 的安裝這里就不贅述了,如果使用 create-react-app 來創建項目,Jest 和 React Testing Library(RTL) 都已經默認安裝了。
如果想要看如何安裝 Jest,可以參考:Jest 上手。
Jest 常用的配置項在根目錄中的 jest.config.js 中,常用的配置可以參考:Jest 配置文件。
2.1 Jest 基礎 API
Jest 的最基礎,最常用的三個 API 是:describe、test 和 expect。
describe 是 test suite(測試套件)
test (也可以寫成 it) 是 test case(測試用例)
expect 是斷言
import aFunction from'./function.js';// 假設 aFunction 讀取一個 bool 參數,并返回該 bool 參數
describe('a example test suite', () => {test('function return true', () => {expect(aFunction(true)).toBe(true);// 測試通過});test('function return false', () => {expect(aFunction(false)).toBe(false);// 測試通過});
});
通過運行?npm run jest?(運行所有的 test suite 和 test case,以及斷言),或者?npm run jest -t somefile.test.tsx(運行指定文件中的測試用例),就可以得到測試結果,如:
當然,如果想要看到覆蓋率的報告,可以使用?jest --coverage,或者?jest-report。
在 VS Code 中,我們也可以安裝插件:Jest Runner。
在代碼中,就可以快速跑測試用例,可以說非常的方便了。
如果在使用 Jest runner 的時候出現 Node.js 相關的報錯,可以查看一下當前 Node.js 的使用版本,切換到 14.17.0 版本即可。
2.2 Jest 匹配器
Jest 匹配器是在 expect 斷言時,用來檢查值是否滿足一定的條件。例如上面的例子中:
expect(aFunction(true)).toBe(true)
其中 toBe () 就是用來比較 aFunction (true) 的值是否為 true。
完整的 Jest 匹配器可以在?這里?查看,下面也列舉一些常用的匹配器:
匹配器 | 說明 |
---|---|
.toBe(value) | 相等性,檢查規則為 === + Object.is |
.toEqual(value) | 相等性,遞歸對比對象字段 |
.toBeInstanceOf(Class) | 檢查是否屬于某一個 Class 的 instance |
.toHaveProperty(keyPath, value) | 檢查斷言中的對象是否包含 keyPath 字段,或可以檢查該對象的值是否等于 value |
.toBeGreaterThan(number) | 大于 number |
.toBeGreaterThanOrEqual(number) | 大于等于 number |
.toBeNaN() | 值是否是 NaN |
.toMatch(regexp or String) | 字符串的相等性,可以填入 string 或者一個正則 |
.toContain(item) | substring |
.toHaveLength(number) | 字符串長度 |
其實在 Testing Library 庫中,還提供了一些匹配器專門用來測試前端組件,這些擴展的匹配器會讓前端組件的測試變得更靈活。除了前端組件的匹配器,一些擴展庫也依據不同的測試場景衍生出了很多其他的匹配器。
2.3 Jest Mock
在查看官方文檔的時候,Jest 匹配器中還有一類匹配器專門用來檢查 Jest Mock 函數的。在組件單測中,有的時候我們可能只關注一個函數是否被正確地調用了,或者只想要某個函數的返回值來支持該組件渲染邏輯是否正確,而并不關心這個函數本身的邏輯。正如官方文檔中強調的那樣:
Testing Library encourages you to avoid testing implementation details like the internals of a component you're testing.
測試庫鼓勵您避免測試實現細節,例如您正在測試的組件的內部結構。
所以,Jest Mock 的意義就在于可以幫助我們完成下面這些事情:
有些模塊可能在測試環境中不能很好地工作,或者對測試本身不是很重要,使用虛擬數據來 mock 這些模塊,可以使你為代碼編寫測試變得更容易;
如果不想在測試中加載這個組件,我們可以將依賴 mock 到一個虛擬組件;
測試組件處于不同狀態下的表現;
mock 一些子組件,可以幫助減小快照的大小,并使它們在代碼評審中保持可讀性;
......
Jest Mock 的常用 API 是:jest.fn () 和 jest.mock ()。
2.3.1 jest.fn()
通過 jest.fn(implementation) 可以創建 mock 函數。如果沒有定義函數內部的實現,mock 函數會返回 undefined。
// 定義一個 mock 的函數,因為沒有函數體,所以 mockFn 會 return undefined
const mockFn = jest.fn();// mockFn 調用
mockFn();
// 雖然沒有定義函數體,但是 mockFn 被調用過了
expect(mockFn).toHaveBeenCalled();const res = mockFn('a','b','c');// 斷言 mockFn 的執行后返回 undefined
expect(res).toBeUndefined();// 斷言mockFn被調用了兩次
expect(mockFn).toBeCalledTimes(2);// 斷言mockFn傳入的參數為a,b,c
expect(mockFn).toHaveBeenCalledWith('a','b','c');// 定義implementation,自定義函數體:
const returnsTrue = jest.fn(() =>true); // 定義了函數體
console.log(returnsTrue()); // true// 可以給mock的函數設置返回值
const returnSomething = jest.fn().mockReturnValue('hello world');
expect(returnSomething()).toBe('hello world');// mock也可以返回一個Promise
const promiseFn = jest.fn().mockResolvedValue('hello promise');
const promiseRes = await promiseFn();
expect(promiseRes).toBe('hello promise');
2.3.2 jest.mock(moduleName, factory, options)
jest.mock() 可以幫助我們去 mock 一些 ajax 請求,作為前端只需要去確認這個異步請求發送成功就好了,至于后端接口返回什么內容我們就不關注了,這是后端自動化測試要做的事情。
// users.js 獲取所有user信息
import axios from'axios';class Users {staticall() {return axios.get('.../users.json').then(resp => resp.data);}
}exportdefault Users;
// user.test.js
import axios from'axios';
import Users from'./users';jest.mock('axios');test('should fetch users', () => {const users = [{name: 'Bob'}];const resp = {data: users};axios.get.mockResolvedValue(resp);// or you could use the following depending on your use case:// axios.get.mockImplementation(() => Promise.resolve(resp))return Users.all().then(data => expect(data).toEqual(users));
});
2.3.3 Jest Mock 的匹配器
Jest 匹配器中還有一類匹配器專門用來檢查 jest mock() 的,比如:
名字
mockFn.mockName(value)
mockFn.getMockName()
運行情況
mockFn.mock.calls
:傳的參數mockFn.mock.results
:得到的返回值mockFn.mock.instances
:mock 包裝器實例
模擬函數
mockFn.mockImplementation(fn)
:重新聲明被 mock 的函數mockFn.mockImplementationOnce(fn)
模擬結果
mockFn.mockReturnThis()
mockFn.mockReturnValue(value)
mockFn.mockReturnValueOnce(value)
mockFn.mockResolvedValue(value)
mockFn.mockResolvedValueOnce(value)
mockFn.mockRejectedValue(value)
mockFn.mockRejectedValueOnce(value)
2.4 Jest 的擴展閱讀材料
Jest 學習指南
那些年錯過的 React 組件單元測試
使用 Jest 測試 JavaScript (Mock 篇)
3、React Testing Library
testing library?是一個測試 React 組件的測試庫,它的核心理念就是:
The more your tests resemble the way your software is used, the more confidence they can give you.
測試越類似于軟件使用方式,就越能給測試信心。
3.1 render & debug
在測試用例中渲染內容,可以使用 RTL 庫中的 render,render 函數可以為我們在測試用例中渲染 React 組件。
被渲染的組件,可以通過 debug 函數或者 screen 的 debug 函數在控制臺輸出組件的 HTML 結構。例如下面的 Dropdown 組件的例子:
import { render, screen } from '@testing-library/react';
import Dropdown from '../index'; // 要測試的組件describe('dropdown test', () => {it('render Dropdown', () => {// 渲染 Dropdown 組件const comp = render(<Dropdown />);comp.debug();screen.debug();// 這兩種都可以打印出來渲染組件的結構});
});
其實,在我們編寫組件測試用例時,都可以通過 debug 函數把組件渲染結果打印出來,這可以提高我們編寫用例時的效率,同時,這一特點也很符合 RTL 的設計觀念。
3.2 screen
在上面的例子中,其實我們也使用到了庫中的 screen。screen 為測試用例提供了一個全局 DOM 環境,通過這個環境,我們就可以去使用庫中提供的不同函數去定位元素,定位后的元素可以用于斷言判斷或者用戶交互。
3.3 定位元素
3.3.1 Query 類型
定位元素的方法在 RTL 中稱為 Query,Query 幫助我們去找到頁面上的元素。RTL 提供了三種 Query 的類型:"get", "find", "query"。
Query 類型 | 未找到元素 | 找到 1 個元素 | 找到多個元素 | Retry (Async/Await) |
---|---|---|---|---|
Single Element | ||||
getBy... | Throw error | Return element | Throw error | No |
queryBy... | Return null | Return element | Throw error | No |
findBy... | Throw error | Return element | Throw error | Yes |
Multiple Elements | ||||
getAllBy... | Throw error | Return array | Return array | No |
queryAllBy... | Return [] | Return array | Return array | No |
findAllBy... | Throw error | Return array | Return array | Yes |
從上面的表格可以看出來,定位的方法在找單個元素時和多個元素時會做了一些區別,比如 getBy... 如果找到了多個元素就會 throw error,這時就需要使用 getAllBy...。
get 和 query 的區別主要是在未找到元素時,queryBy 會返回 null,這對于我們測試一個元素是否存在時非常有幫助。
而 findby 的作用主要用于那些最終會顯示在頁面當中的異步元素。
3.3.2 Query 內容
那么,getBy...、queryBy... 和 findBy... 后面具體可以查詢什么內容呢?
主要
ByLabelText:用于表單的 label
ByPlaceholderText:用于表單
ByText:查詢 TextNode
ByDisplayValue:輸入框等當前值
語義
ByAltText:img 的 alt 屬性
ByTitle:title 屬性或元素
ByRole:ARIA role,可以定位到輔助樹中的元素
Id
getByTestId:函數需要在源代碼中添加 data-testid 屬性才能使用
一般而言,getByText 和 getByRole 應該是元素的首選定位類型。
import { render, screen } from'@testing-library/react';
import Dropdown from'../index'; // 要測試的組件const propsRender = {commonStyle: {},data: {btnTheme: 'default',btnVariant: 'text',btnText: 'test', // 給 dropdown 的 button 設置文字 'test'trigger: 'click',},style: {},meta: {previewMode: true,isEditor: false},on: jest.fn(),off: jest.fn(),emit: jest.fn(),
};describe('dropdown test', () => {it('render Dropdown', () => {// 渲染 Dropdown 組件const comp = render(<Dropdown />);// 使用 queryByText("test") 定位這個 button 的文字內容,然后使用斷言+匹配做測試expect(screen.queryByText("test")).toBeInTheDocument();});
});
findBy 的使用方法
假如在 Component 組件中定義一行文字 “hello world” 和一個定時器,在組件渲染 3 秒后再顯示這行字。
describe('test hello world', () => {test('renders component', async () => {render(<Component />);// 在組件的初始化渲染中,我們在 HTML 中無法通過 queryBy 找到 “hello world”,因為它三秒后才能出現expect(screen.queryByText(/hello world/)).toBeNull();// await 一個新的元素被找到,并且最終確實被找到當 promise resolves 并且組件重新渲染之后。expect(await screen.findByText(/hello world/)).toBeInTheDocument();});
});
對于任何開始不顯示、但遲早會顯示的元素,要使用 findBy。如果你想要驗證一個元素不在頁面中,使用 queryBy,否則默認使用 getBy。
RTL 所有定位方法可?點擊?查看。
3.4 RTL +?Jest 匹配器
在?2.2 Jest 匹配器?中可以看到 Jest 提供了一些匹配器,然而 Jest 自己提供的匹配器很難去實現組件測試的一些特殊條件,所以 RTL 自己實現了一個 Jest 匹配器的擴展包:jest-dom。
Custom matchers
toBeDisabled
toBeEnabled
toBeEmptyDOMElement
toBeInTheDocument
toBeInvalid
toBeRequired
toBeValid
toBeVisible
toContainElement
toContainHTML
toHaveAccessibleDescription
toHaveAccessibleName
toHaveAttribute
toHaveClass
toHaveFocus
toHaveFormValues
toHaveStyle
toHaveTextContent
toHaveValue
toHaveDisplayValue
toBeChecked
toBePartiallyChecked
toHaveErrorMessage
Deprecated matchers
toBeEmpty
toBeInTheDOM
toHaveDescription
3.5 事件:FireEvent
實際的用戶交互可以通過 RTL 的 fireEvent 函數去模擬。
fireEvent(node: HTMLElement, event: Event)
fireEvent[eventName](node: HTMLElement, eventProperties: Object)// <button>Submit</button>
fireEvent(getByText(container, 'Submit'),new MouseEvent('click', {bubbles: true,cancelable: true,}),
);// 兩種寫法
fireEvent(element, new MouseEvent('click', options?));
fireEvent.click(element, options?);
fireEvent 函數需要兩個參數,一個參數是定位的元素 node,另一個參數是 event。這個例子中就模擬了用戶點擊了 button,同時 fireEvent 有兩種寫法。
事件 options 描述
屬性 / 方法 | 描述 |
---|---|
bubbles | 返回特定事件是否為冒泡事件。 |
cancelBubble | 設置或返回事件是否應該向上層級進行傳播。 |
cancelable | 返回事件是否可以阻止其默認操作。 |
composed | 指示該事件是否可以從 Shadow DOM 傳遞到一般的 DOM。 |
composedPath() | 返回事件的路徑。 |
createEvent() | 創建新事件。 |
currentTarget | 返回其事件偵聽器觸發事件的元素。 |
defaultPrevented | 返回是否為事件調用 preventDefault () 方法。 |
eventPhase | 返回當前正在評估事件流處于哪個階段。 |
isTrusted | 返回事件是否受信任。 |
target | 返回觸發事件的元素。 |
timeStamp | 返回創建事件的時間(相對于紀元的毫秒數)。 |
type | 返回事件名稱。 |
常用 fireEvent:
鍵盤:
keyDown
keyPress
keyUp
聚焦:
focus
blur
表單:
change
input
invalid
submit
reset
鼠標:
click
dblClick
drag
fireEvent API 列表可?點擊?查看。
4、寫在最后
測試在整個需求開發的流程中起著重要作用,它對于需求產品的質量提供了強而有力的保障。但是在實際的工作中,產品的迭代、需求的變更以及各種不確定的因素,我們經常會陷入“bug的輪回” —— 關上一個bug,點亮另一個bug。
隨著業務復雜度的提升,測試的人力成本也會越來越高。面對這些痛點,作為“懶而聰明”的前端開發,我也常常在思考有什么方法可以在解放雙(ren)手(li)的同時,又能保證產品的質量,也不必在每次需求上線時緊張兮兮地盯著告警看板,生怕發的版本影響了其他的功能。所以,我相信借助于測試的力量,這些痛點終有一天會逐個擊破。
就像開頭提到的,本文只是“比較粗略”地瀏覽了 Jest + RTL,相較于整個前端單測來說只是冰山一角。希望在日后工作的每一天能不斷地探索這個領域,也希望在不久的將來,我也能 “快樂編碼,自信發布”。
·················?若川簡介?·················
你好,我是若川,畢業于江西高校。現在是一名前端開發“工程師”。寫有《學習源碼整體架構系列》20余篇,在知乎、掘金收獲超百萬閱讀。
從2014年起,每年都會寫一篇年度總結,已經寫了7篇,點擊查看年度總結。
同時,最近組織了源碼共讀活動,幫助3000+前端人學會看源碼。公眾號愿景:幫助5年內前端人走向前列。
識別上方二維碼加我微信、拉你進源碼共讀群
今日話題
略。分享、收藏、點贊、在看我的文章就是對我最大的支持~