元件測試實務

元件測試是開始示範實際測試程式碼的好地方。元件測試比簡單的單元測試更實質,比起端到端測試也較不複雜,且可展示與 DOM 的互動情形。就概念上來說,使用 React 可讓網頁開發人員更輕鬆地將網站或網頁應用程式視為元件是由元件組成。

因此,無論元件複雜程度為何,測試個別元件都是開始思考測試新應用程式或現有應用程式的好方法。

本頁面將逐步說明如何測試具有複雜外部依附元件的簡易元件。您可以輕鬆測試未與其他程式碼互動的元件,例如按下按鈕並確認數字增加。實際上,很少程式碼會是這種情況,而且測試沒有互動的程式碼價值有限。

受測試的元件

我們使用 Vitest 及其 JSDOM 環境來測試 React 元件。這樣一來,我們就能在模擬瀏覽器的同時,使用 Node 在指令列上快速執行測試。

名單清單,每個名稱旁都有「選擇」按鈕。
這個小型 React 元件會顯示來自網路的使用者清單。

這個名為 UserList 的 React 元件會從網路擷取使用者清單,並讓您選取其中一個使用者。系統會使用 useEffect 內的 fetch 取得使用者清單,再由 Context 傳入選取處理常式。程式碼如下:

import React, { useEffect, useState, useContext } from 'react';
import { UserContext } from './UserContext.tsx';
import { UserRow } from './UserRow.tsx';

export function UserList({ count = 4 }: { count?: number }) {
  const [users, setUsers] = useState<any[]>([]);
  useEffect(() => {
    fetch('https://jsonplaceholder.typicode.com/users?_limit=' + count)
      .then((response) => response.json())
      .then((json) => setUsers(json));
  }, [count]);

  const c = useContext(UserContext);
  return (
    <div>
      <h2>Users</h2>
      <ul>
        {users.map((u) => (
          <li key={u.id}>
            <button onClick={() => c.userChosen(u.id)}>Choose</button>{' '}
            <UserRow u={u} />
          </li>
        ))}
      </ul>
    </div>
  );
}

這個範例並未示範 React 最佳做法 (例如,它會在 useEffect 中使用 fetch),但您的程式碼集可能包含許多類似的情況。更重要的是,這些案例乍看之下可能很難測試。本課程的後續章節將詳細討論如何編寫可測試的程式碼。

以下是我們在本例中測試的項目:

  • 確認系統會根據網路資料建立正確的 DOM。
  • 確認點選使用者會觸發回呼。

每個元件都不盡相同。為什麼測試這個測試案例很有趣?

  • 它會使用全域 fetch 從網路要求實際資料,這些資料在測試期間可能會不穩定或速度緩慢。
  • 它會匯入另一個類別 (UserRow),我們可能不想隱含測試。
  • 它使用 Context,但這並不是受測試的程式碼的一部分,而且通常由父項元件提供。

開始編寫快速測試

我們可以快速測試這個元件的基本功能。請注意,這個範例並不實用。不過,在名為 UserList.test.tsx 的對等檔案中設定範本很有幫助 (請注意,Vitest 等測試執行程式預設會執行結尾為 .test.js 或類似字串的檔案,包括 .tsx):

import { vi, test, assert, afterAll } from 'vitest';
import { render } from '@testing-library/react';
import { UserList } from './UserList.tsx';
import React, { ContextType } from 'react';

test('render', async () => {
  const c = render(<UserList />);

  const headingNode = await c.findAllByText(/Users);
  assert.isNotNull(headingNode);
});

這項測試會宣告元件轉譯時包含「使用者」文字。即使元件會將 fetch 傳送至網路,還是可以正常運作。在測試結束時,fetch 仍在進行中,且沒有設定端點。我們無法確認測試結束時是否會顯示任何使用者資訊,至少需要等待逾時才行。

模擬 fetch()

模擬是指將實際函式或類別替換為您可控制的測試項目。這幾乎是所有類型測試的常見做法,除了最簡單的單元測試。如需進一步瞭解這項功能,請參閱「斷言和其他基本元素」一文。

您可以為測試模擬 fetch(),讓測試快速完成並傳回預期的資料,而非「實際」或不明的資料。fetch全域,也就是說我們不需要將其importrequire至程式碼中。

在 vitest 中,您可以使用 vi.fn() 傳回的特殊物件呼叫 vi.stubGlobal,藉此模擬全域變數,之後再修改模擬資料。本課程稍後會更詳細探討這些方法,但您可以在下列程式碼中看到實際應用方式:

test('render', async () => {
  const fetchMock = vi.fn();
  fetchMock.mockReturnValue(
    Promise.resolve({
      json: () => Promise.resolve([{ name: 'Sam', id: 'sam' }]),
    }),
  );
  vi.stubGlobal('fetch', fetchMock);

  const c = render(<UserList />);

  const headingNode = await c.queryByText(/Users);
  assert.isNotNull(headingNode);

  await waitFor(async () => {
    const samNode = await c.queryByText(/Sam);
    assert.isNotNull(samNode);
  });
});

afterAll(() => {
  vi.unstubAllGlobals();
});

這個程式碼會新增模擬畫面,描述網路擷取 Response 的假版本,然後等待模擬畫面顯示。如果沒有看到文字,您可以將 queryByText 中的查詢變更為新名稱,藉此確認這項情況。如果沒有看到文字,測試就會失敗。

這個範例使用 Vitest 內建的模擬輔助程式,但其他測試架構也有類似的模擬方法。Vitest 的不重複之處,在於您必須在所有測試後呼叫 vi.unstubAllGlobals(),或設定對等的全域選項。如果不「撤銷」我們的作業,fetch 模擬資料可能會影響其他測試,而且每個要求都會以我們的奇怪 JSON 回應。

模擬匯入

您可能已經注意到,我們的 UserList 元件本身會匯入名為 UserRow 的元件。雖然我們未納入程式碼,但您可以看到它會轉譯使用者名稱:先前的測試會檢查「Sam」,而這並未直接在 UserList 中轉譯,因此必須來自 UserRow

顯示使用者名稱在元件中移動方式的流程圖。
UserListTest 無法查看 UserRow

不過,UserRow 本身可能是複雜的元件,它可能會擷取更多使用者資料,或有與測試無關的副作用。移除這種變化性可讓測試更實用,尤其是當您要測試的元件變得更複雜,且與其依附元件更加交錯時。

幸運的是,即使測試未直接使用特定匯入項目,您也可以使用 Vitest 模擬特定匯入項目,如此一來,使用該匯入項目的任何程式碼都能透過簡單或已知版本提供:

vi.mock('./UserRow.tsx', () => {
  return {
    UserRow(arg) {
      return <>{arg.u.name}</>;
    },
  }
});

test('render', async () => {
  // ...
});

如同模擬 fetch 全域所示,這是一項強大工具,但如果程式碼含有大量依附元件,就無法維持持久性。同樣地,最佳修正方式是編寫可測試的程式碼。

點選並提供背景資訊

React 和其他程式庫 (例如 Lit) 都有一個名為 Context 的概念。範例程式碼包含 UserContext,如果選擇使用者,就會叫用方法。這通常是「屬性鑽研」的替代方案,在這種情況下,回呼會直接傳遞至 UserList

我們的測試控管工具未提供 UserContext。在沒有點擊動作的情況下,將點擊動作新增至 React 測試,最糟的情況是導致測試異常終止。最佳情況下,如果在其他地方提供預設執行個體,則可能會導致我們無法控制某些行為 (類似上述的不明 UserRow)。

  const c = render(<UserList />);
  const chooseButton = await c.getByText(/Choose);
  chooseButton.click();

相反地,您可以在轉譯元件時提供自己的 Context。這個範例使用 vi.fn() 的例項,也就是 Vitest 模擬函式,可用於檢查是否已發出呼叫,以及所使用的引數。

在本例中,這會與先前範例中的模擬 fetch 互動,且測試可以確認傳入的 ID 為 sam

  const userChosenFn = vi.fn();
  const ucForTest: ContextType<typeof UserContext> = { userChosen: userChosenFn as any };
  const c = render(
    <UserContext.Provider value={ucForTest}>
      <UserList />
    </UserContext.Provider>,
  );

  const chooseButton = await c.getByText(/Choose);
  chooseButton.click();
  assert.deepStrictEqual(userChosenFn.mock.calls, [['sam']]);

這是一個簡單但功能強大的模式,可讓您從要測試的核心元件中移除不相關的依附元件。

摘要說明

這個範例示範如何建構元件測試,以便測試及保護難以測試的 React 元件。這項測試著重於確保元件可與其依附元件正確互動:fetch 全域、匯入的子元件和 Context

進行隨堂測驗

您使用哪些方法測試 React 元件?

使用結構定義插入依附元件
堆積全球
檢查數字是否遞增
使用簡單的模擬項目模擬複雜的依附元件,以利測試