Testing library 101 (一)

概述

Testing-library 是 React 官方推薦的單元測試庫,對(duì)標(biāo)的是 Airbnb 的 Enzyme。我試著用現(xiàn)在流行的一套話術(shù)體系(發(fā)現(xiàn)問題、分析問題、解決問題)來解釋一下 Testing-library 的特點(diǎn):

  1. Testing-library 的設(shè)計(jì)者發(fā)現(xiàn)了一個(gè)問題:從前的 unit test 主要著眼于組件內(nèi)部屬性的斷言,但是開發(fā)者們覺得這種測試方法有點(diǎn)自欺欺人

  2. 為了提升開發(fā)者對(duì)自己 test case 的信心,他們提出了一個(gè)理念:只有更接近于軟件使用方式的測試,大家才覺得更可靠

  3. 然后他們設(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();
});

解釋一下:

  1. 利用 getByText 找到一個(gè)包含文本 Hello World 的一個(gè)元素
  2. 斷言這個(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 又包含TextRole、PlaceholderText等等。全選搜索返回的就是一個(gè)數(shù)組,測試方法類似,就是多了個(gè)循環(huán)罷了,這里就不展開了。

事件

接著我們再談?wù)勗趺礈y試 UI 的事件。我們不看源碼,只看組件 UI 效果。

input

該組件的功能簡單來說就是:在輸入框內(nèi)輸入文字,它上頭就會(huì)顯示相應(yīng)的文本。如果你是 tester,你會(huì)怎么測試?我想具體來說就三步:

  1. 選中輸入框
  2. 輸入文字
  3. 確認(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();
});

是不是挺直觀的?

  1. 找到輸入框 $input;這里 <input> 標(biāo)簽的 role 是 textbox(不知道 role 是啥?沒關(guān)系,getByRole(瞎寫一個(gè)),控制臺(tái)會(huì)告訴你的)

  2. userEvent.type 來模擬用戶輸入文字

  3. 再斷言相應(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)擊下文連接。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容