Testing library 101 (二)

書接上文,上篇說到了 React Testing library 的安裝和最基本用法。本篇繼續(xù)深挖一些較復雜的場景。

mock 測試

開始 RTL 測試前,我們稍微回顧一下 Jest 的 Mock 測試。

所謂 mock 測試就是在測試過程中,對于某些不容易構造或者不容易獲取的對象,用一個虛擬的對象代替具體實現(xiàn),以便繼續(xù)測試進度的方式。

其中最常用的 mock 方式有兩種:

  1. 創(chuàng)建一個 mock 函數(shù),注入到目標用例里, 如 jest.fn
  2. 設計一個手工的 mock 對象,來覆蓋依賴模塊,如 jest.mock

jest.fn()

先來說 mock 函數(shù)注入。我們寫一個最最基礎的 repeatTen 函數(shù),功能就是調(diào)用10 次其他函數(shù)。

function repeatTen(fn) {
  for (let i = 0; i < 10; i++) {
    fn();
  }
}

repeatTen(() => console.log("Onion"));

repeatTen 函數(shù)的特點就是:它根本不關心 fn 的具體實現(xiàn),只要保證 fn 一次性能跑到10趟就行。這類單元測試就非常適合使用 mock 函數(shù)了。如下所示,我們以參數(shù)的形式,為 repeatTen 傳入一個 mock 函數(shù)(onChang),然后檢查 mock 函數(shù)的調(diào)用狀態(tài),判定函數(shù)是否如期運行就行了。

it("Count the calls of the onChange", () => {
  const onChange = jest.fn();
  repeatTen(onChange);
  // assert that it is called 10 times
  expect(onChange).toHaveBeenCalledTimes(10);
});

jest.mock()

上文 repeatTen 函數(shù)的實現(xiàn)比較單純,現(xiàn)實世界則復雜很多;有些函數(shù)的實現(xiàn)會依賴于三方庫。在測試環(huán)境里你很難以傳入一個 mock 函數(shù)的形式來模擬真實的運行狀態(tài)。比如下面這個函數(shù),通過 axios 調(diào)用 API 來返回數(shù)據(jù),大家想想該怎么寫 test case。

const loadStories = () => axios.get("/stories");

我們要測試 loadStories 但又不大可能調(diào)用真實的 API,就只能對 axios 的內(nèi)部模塊動點小腦筋了——用 jest.mock("axios") 自動模擬 axios 模塊。

Jest 提供了一個很有意思的依賴覆蓋方法——jest.mock("axios"),它會給對象模塊——axios——的.get方法提供一個 mockResolvedValue。通俗來說,就是讓axios.get("/stories") 返回一個假的 response;而這個 response 的數(shù)據(jù)是我們事先準備好的。

看一下寫 mock 依賴測試的主要流程:

  1. 利用 jest.mock(...) 覆蓋用例函數(shù)內(nèi)部 import 的依賴
  2. 為用例函數(shù)內(nèi)部使用的某個方法構造一個 mock 返回
  3. 斷言用例函數(shù)的返回結果
import axios from "axios";

// step1: override a module
jest.mock("axios");

const mockStories = [
  { objectID: "1", title: "Hello" },
  { objectID: "2", title: "React" },
];

it("load stories by axios", async () => {
  // step2: make a fake response
  const response = { data: mockStories };
  axios.get.mockResolvedValue(response);

  // step3: assert the data
  const { data } = await loadStories();
  expect(data).toEqual(mockStories);
});

callback 測試

OK,兜兜轉轉說了很長篇幅的 Jest 方法。我們還是回到 React Testing library(以下簡稱RTL)。

如果大家看懂了 Jest.fn 章節(jié)的內(nèi)容,RTL 的 callback 測試就一目了然了——就是來測試 onChange 事件的。我們寫一個簡單的搜索條,通過 onChange 事件返給父組件輸入的內(nèi)容。

// CallbackSearch.js
export function CallbackSearch({ onChange }) {
  return (
    <div>
      <label htmlFor="search">Search:</label>
      <input id="search" type="text" onChange={onChange} />
    </div>
  );
}

該組件的測試怎么寫呢?很簡單,反正只有一個 onChange 參數(shù),我們只要測試它的調(diào)用狀態(tài)就行了。

import userEvent from "@testing-library/user-event";

describe("Search", () => {
  it("paste counts", () => {
    const onChange = jest.fn();
    render(<CallbackSearch onChange={onChange} />);

    const $e = screen.getByRole("textbox");
    userEvent.paste($e, "Onion");

    expect(onChange).toHaveBeenCalledTimes(1);
  });
});

這次我們使用的是 userEvent 的paste方法,輸入框內(nèi)黏貼一段文本,自然只觸發(fā)一次事件,所以斷言 onChange 被調(diào)用了 1 次即可。假如你用的是userEvent.type,那就是每輸入一個字符都會回調(diào)一次,就要斷言 onChange 的掉用次數(shù)為輸入字符串的長度了。

異步加載測試

上一章講了 jest.fn 的案例,我們再說說 jest.mock 的案例。

React Hook

現(xiàn)在不是流行寫 Hook 嗎?我們就寫個 loadStories 的加強版 Hook,主要功能還是老樣子,利用 axios 遠程調(diào)用 API;成功了就把數(shù)據(jù)寫到 stories 這個 state 里,失敗了就把錯誤寫到 error 這個 state 里。

// userStory.js
import axios from "axios";
import { useState, useCallback } from "react";

export function useStory() {
  const [stories, setStories] = useState([]);
  const [error, setError] = useState(null);

  const handleFetch = useCallback(() => {
    const loadStories = async () => {
      try {
        const { data } = await axios.get("/stories");
        setStories(data);
      } catch (error) {
        setError(error);
      }
    };
    loadStories();
  }, []);

  return { error, stories, handleFetch };
}

給 React Hook 寫單元測試需要額外安裝一個依賴,@testing-library/react-hooks;主要是用了它的一個方法 readerHook 來模擬 react 組件的場景。

我們看一下怎么寫這個 Hook 的 test case,套路是相似的:

  1. 利用 jest.mock 覆蓋 axios 模塊
  2. 偽造一個 axios.get 的 response
  3. 斷言 stories 的初試狀態(tài)是空數(shù)組
  4. 調(diào)用異步方法加載 stories
  5. 完成異步調(diào)用后,斷言 stories 的最新狀態(tài)
import { renderHook } from "@testing-library/react-hooks";
import axios from "axios";
import { useStory } from "./useStory";

// Step 1: override a module
jest.mock("axios");

const mockStory = [
  { objectID: "1", title: "Hello" },
  { objectID: "2", title: "React" },
];

it("load stories and succeed", async () => {
  // Step 2: fake a response
  const response = { data: mockStory };
  axios.get.mockResolvedValue(response);

  const { result, waitForNextUpdate } = renderHook(() => useStory());

  // Step 3: test initial state
  expect(result.current.stories).toEqual([]);

  // Step 4: fetch stories
  result.current.handleFetch();

  // Step 5: test after load axios api
  await waitForNextUpdate();
  expect(result.current.stories).toEqual(mockStory);
});

React Component

測試完 Hook,我們把它放到組件里。老規(guī)矩,先不看實現(xiàn),看效果:

FetchButton

簡單來說就是寫了一個 button,點擊后會異步調(diào)用數(shù)據(jù),然后展示出所有的 stories 條目。

還是照搬上面的套路:

  1. 利用 jest.mock 覆蓋 axios 模塊
  2. 偽造一個 axios.get 的 response
  3. 點擊按鈕,即異步加載 stories
  4. 結束后,斷言屏幕上會出現(xiàn)了相應的 story 條目
import axios from "axios";

// Step 1: override a module
jest.mock("axios");

const mockStory = [
  { objectID: "1", title: "Hello" },
  { objectID: "2", title: "React" },
];

describe("FetchButton", () => {
  it("fetches stories from an API and displays them", async () => {
    render(<FetchButton />);

    // Step 2: fake a response
    const response = { data: mockStory };
    axios.get.mockResolvedValue(response);

    // Step 3: click button
    userEvent.click(screen.getByRole("button"));

    // Step 4: assert that screen will display 2 items
    const $items = await screen.findAllByRole("listitem");
    expect($items).toHaveLength(2);
  });
});

教課書里的 TDD 要求先寫測試,再寫實現(xiàn)的。我這里也盡量按照這個思路排版,大家看完單元測試,也應該有了大體的組件實現(xiàn)輪廓了吧?我把我的實現(xiàn)寫出來:

// FetchButton.js
import { useStory } from "./useStory";

export function FetchButton() {
  const { error, stories, handleFetch } = useStory();

  return (
    <div>
      <button onClick={handleFetch}>Fetch Stories</button>

      {error && <span>Something went wrong ...</span>}

      <ul>
        {stories.map((story) => (
          <li key={story.objectID}>{story.title}</li>
        ))}
      </ul>
    </div>
  );
}

異常測試

我們接著說上面的 FetchButton。在寫實現(xiàn)的時候,我發(fā)現(xiàn)了之前測試里有個重大疏漏:異步調(diào)取的 API 可能會返回錯誤,之前的單元測試沒有覆蓋到拋異常的情況。所以我又在實現(xiàn)里加了個 error 的判斷;有錯誤就顯示 Something went wrong ...。既然有疏漏,就繼續(xù)補 test case。這種測試其實更簡單,基本上就是套用上文的步驟,唯一不同之處就是讓 axios.get 返回mockRejectedValue。大家看看下面的 test case,應該已經(jīng)沒有難點了。

jest.mock("axios");

describe("FetchButton", () => {

  it("fetch stories from an API but fail", async () => {

    render(<FetchButton />);

+    axios.get.mockRejectedValue(new Error()));

    userEvent.click(screen.getByRole("button"));

    const $errorMsg = await screen.findByText(/Something went wrong/);

    expect($errorMsg).toBeInTheDocument();
  });
});

小結

我最近又回看了2020 年 JS 滿意度調(diào)查,發(fā)現(xiàn) Testing Library 位居測試榜榜首,還是很有群眾基礎的。我們用了兩期時間介紹了 Testing Library 的入門教程,也希望大家能盡快把 TDD 落實到自己的項目中。我見過好多項目幾乎沒有任何測試,一兩年就爛掉了;一加新功能就四處漏風,后期 bug 數(shù)量急速上升,release 甚至能因此拖延半年之久;屎山一堆積,想改就再也改不了了。TDD 是軟件行業(yè)多年來的最佳實踐,我們作為該行業(yè)的從業(yè)人員,也應該堅定地按行業(yè)規(guī)律辦事;這不僅是“干活勤快”這么簡單,更多的是自己職業(yè)素養(yǎng)的體現(xiàn),共勉。

State of JS 2020

相關

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內(nèi)容