概述
Testing-library 是 React 官方推薦的單元測試庫,對(duì)標(biāo)的是 Airbnb 的 Enzyme。我試著用現(xiàn)在流行的一套話術(shù)體系(發(fā)現(xiàn)問題、分析問題、解決問題)來解釋一下 Testing-library 的特點(diǎn):
Testing-library 的設(shè)計(jì)者發(fā)現(xiàn)了一個(gè)問題:從前的 unit test 主要著眼于組件內(nèi)部屬性的斷言,但是開發(fā)者們覺得這種測試方法有點(diǎn)自欺欺人
為了提升開發(fā)者對(duì)自己 test case 的信心,他們提出了一個(gè)理念:只有更接近于軟件使用方式的測試,大家才覺得更可靠
然后他們設(shè)計(jì)了一堆 API,來模擬用戶找尋或操作 DOM 的方式
OK,一句話解釋就是 Testing-library 提供了一系列擬人化的 API 來幫助我們測試 UI 組件。至于如何擬人化,請(qǐng)看下方示例。
安裝
本文以 react 為例,如果大家使用的是 create-react-app 新建的應(yīng)用,可以跳過本章;如果沒有,就需要安裝如下兩個(gè)依賴:
yarn add -D @testing-library/react @testing-library/user-event
這里說明一下,testing-library 是 library 不是 framework。Framework 遵守的是好萊塢原則(Don't call me, I'll call you), 而 library 通常需要主動(dòng) import 方法。testing-library 實(shí)現(xiàn)了所有主流測試框架的集成,本文選用了 React 官推的 Jest+testing-library 組合,所以還需要安裝@testing-library/jest-dom:
yarn add - D @testing-library/jest-dom
方便起見,通常還會(huì)給 jest 添加一個(gè)啟動(dòng)文件用于導(dǎo)入@testing-library/jest-dom;當(dāng)然你也可以省略這一步,只是每個(gè)測試文件里都要加下面這一句,有點(diǎn)麻煩罷了。
// setupTests.js
import "@testing-library/jest-dom";
// package.json
{
"jest": {
"setupFilesAfterEnv": ["setupTests.js"]
}
}
Get started
安裝完成后,我們就開始第一個(gè) test case。先寫一個(gè) Hello World 的組件:
// Title.js
import React from "react";
export const Title = () => <h1>Hello World</h1>;
React Testing Library(以下簡稱RTL)的測試內(nèi)容大致如下,通過一個(gè)叫 render 的方法來渲染 React 組件(VUE,Angular, Svelte 框架相關(guān)的 Testing Library 測試也大體相同):
// Title.test.js
import React from "react";
import { Title } from "./Title";
+ import { render } from "@testing-library/react";
describe("Title", () => {
test("debug Title", () => {
+ render(<Title />);
});
});
一般初學(xué)者都想看一下 render 的結(jié)果,那試試 screen.debug():
// Title.test.js
import { render, screen } from "@testing-library/react";
describe("Title", () => {
test("debug Title", () => {
render(<Title />);
screen.debug();
});
});
運(yùn)行jest,控制臺(tái)輸出如下:是一塊 html document。React 組件的 DOM 被包裹在<body>——render 函數(shù)默認(rèn)的 container——里面。
<body>
<div>
<h1>Hello World</h1>
</div>
</body>
通常來說,我們寫 test case 不會(huì)直接打印出這個(gè)渲染的 DOM,但是大家心里得明白,所有的測試方法都是基于這個(gè) render 方法的渲染結(jié)果。
選擇元素
當(dāng)然,僅僅利用庫方法渲染出 Document 并不能稱為一個(gè)測試用例;我們至少需要一個(gè)斷言:比如,斷定某個(gè)元素會(huì)出現(xiàn)在渲染后的 Document 中。我們在 render 后加上如下兩行代碼:
// Title.test.js
test("getByText of Title", () => {
render(<Title />);
+ const $e = screen.getByText("Hello World");
+ expect($e).toBeInTheDocument();
});
解釋一下:
- 利用
getByText找到一個(gè)包含文本Hello World的一個(gè)元素 - 斷言這個(gè)元素在 Document 中
再次運(yùn)行jest,測試通過;一個(gè)最最基礎(chǔ)的 test case 就完工了。
Title
√ getByText of Title (29 ms)
Test Suites: 1 passed, 1 total
Tests: 1 skipped, 1 passed, 1 total
上面這個(gè) case 中,我們用到了 getByText——利用文本查看目標(biāo)元素,大家有沒有覺得很像某個(gè)場景:在瀏覽器里對(duì)著某段文本 inspect,然后找到目標(biāo)元素呢?這就是 RTL API 的獨(dú)特之處:模擬開發(fā)者的用例操作。
此外,還有如下幾個(gè) API 也能幫我們查看元素。但是大家有沒有注意到,這里竟然沒有選擇器(selector)?!這就是上文提到的擬人化特色:你不用去深入了解組件內(nèi)部具體用到了什么 ID,或是什么類;你只需模糊地意識(shí)到有這么一個(gè) html tag 就可以開始測試了。
- getByRole('button'):
<button>click me</button> - getByLabelText('search'):
<label for="search" /> - getByPlaceholderText('Search'):
<input placeholder="Search" /> - getByAltText('profile):
<img alt="profile" /> - getByDisplayValue('Javascript):
<input value="JavaScript" />
搜索變量
getByText
我們接著說getByText。上文用到 getByText('Hello World'),用的是全文本匹配搜索,事實(shí)上該方法還支持正則表達(dá)式。下面這個(gè) case 也能過;只要知道某個(gè)文本的變量,getByText 就可以幫忙搜索到目標(biāo)元素了。
test("getByText by regular expression", () => {
render(<Title />);
const $e = screen.getByText(/Hello/);
expect($e).toBeInTheDocument();
});
queryByText
getByText 之外,RTL 還提供了個(gè)一個(gè)類似的方法,叫 queryByText。它倆的區(qū)別是:getByText 找不到元素時(shí),會(huì)直接拋異常,test case 會(huì)隨之中斷報(bào)錯(cuò);而 queryByText 在這種情況下是返回 null,所以 queryBy 常用于斷言某個(gè)元素不存在于 Document 中。這種測試挺常見的,比如傳個(gè)參數(shù)到組件里讓它隱藏掉某個(gè)元素什么的。
test("search queryByText of Title", () => {
render(<Title />);
const $e = screen.queryByText(/Onion/);
expect($e).toBeNull();
});
findByText
第三個(gè)類似的方法叫 findByText,它是一個(gè)異步函數(shù);用在一些需要異步渲染的組件測試上:
// AsyncTitle.js
import React, { useState, useEffect } from "react";
export const AsyncTitle = () => {
const [user, setUser] = useState(null);
useEffect(() => {
const loadUser = async () => {
await "simulate a promise";
setUser("Onion");
};
loadUser();
});
return user && <h1>Hello {user}</h1>;
};
// AsyncTitle.test.js
test("findByText of AsyncTitle", async () => {
render(<AsyncTitle />);
const $e = await screen.findByText(/Hello/);
expect($e).toBeInTheDocument();
});
多元素選擇
上面提到的都是選擇第一個(gè)出現(xiàn)的元素。自然有全選的情況,API 也是類似上述的三套——getAllBy、queryAllBy、findAllBy。每套 API 又包含Text、Role、PlaceholderText等等。全選搜索返回的就是一個(gè)數(shù)組,測試方法類似,就是多了個(gè)循環(huán)罷了,這里就不展開了。
事件
接著我們再談?wù)勗趺礈y試 UI 的事件。我們不看源碼,只看組件 UI 效果。

該組件的功能簡單來說就是:在輸入框內(nèi)輸入文字,它上頭就會(huì)顯示相應(yīng)的文本。如果你是 tester,你會(huì)怎么測試?我想具體來說就三步:
- 選中輸入框
- 輸入文字
- 確認(rèn)輸入的文本已顯示
看看我們的 unit test 怎么寫:
// InputTitle.test.js
import userEvent from "@testing-library/user-event";
test("type InputTitle", () => {
render(<InputTitle />);
// Step 1
const $input = screen.getByRole("textbox");
// Step 2
const val = "Hello World";
userEvent.type($input, val);
// Step 3
const $text = screen.getByText(val);
expect($text).toBeInTheDocument();
});
是不是挺直觀的?
找到輸入框
$input;這里<input>標(biāo)簽的 role 是textbox(不知道 role 是啥?沒關(guān)系,getByRole(瞎寫一個(gè)),控制臺(tái)會(huì)告訴你的)用
userEvent.type來模擬用戶輸入文字再斷言相應(yīng)的輸入文本已經(jīng)顯示在 Document 里了
回過頭來看一眼這個(gè)組件的源碼;大家有沒有感受到,即便不知道具體實(shí)現(xiàn),也是可以寫 UI 測試的?
// InputTitle.js
import React, { useState } from "react";
export const InputTitle = () => {
const [head, setHead] = useState("");
return (
<div>
<h1>{head}</h1>
<input
type="text"
value={head}
onChange={(e) => setHead(e.target.value)}
/>
</div>
);
};
最后,再說一下上面用到的 @testing-library/user-event 庫, 它為 RTL 提供了一整套用戶操作集: 除了type,還包括如下幾種,大家有空可以試一下,都非常直觀。
- click(element, eventInit, options)
- dblClick(element, eventInit, options)
- type(element, text, [options])
- upload(element, file, [{ clickInit, changeInit }])
- clear(element)
- selectOptions(element, values)
- deselectOptions(element, values)
- tab({shift, focusTrap})
- hover(element)
- unhover(element)
- paste(element, text, eventInit, options)
- specialChars
小結(jié)
好多老前端都不寫 unit test,一說原因就是 UI 測試太難寫了。這期看了 RTL 的入門案例,大家有沒有動(dòng)搖呢;其實(shí)前端測試也沒那么難寫,是吧?
這期是 Testing-library 101 的上篇,下篇將包括一些進(jìn)階版的測試案例,如回調(diào)、異步更新、錯(cuò)誤捕獲等等,有興趣的小伙伴可以點(diǎn)擊下文連接。