コンポーネント テストは、実践的なテストコードを示すのに適しています。コンポーネント テストは、単純な単体テストよりも内容が充実しており、エンドツーエンド テストほど複雑でなく、DOM の操作のデモを行います。より哲学的な観点から言えば、React の使用により、ウェブ デベロッパーはウェブサイトやウェブアプリをコンポーネントで構成されていると考えるほうが簡単になりました。
したがって、複雑さにかかわらず個々のコンポーネントをテストすることは、新規または既存のアプリケーションのテストについて検討する良い方法です。
このページでは、複雑な外部依存関係を持つ小さなコンポーネントをテストする手順について説明します。他のコードとやり取りしないコンポーネントは簡単にテストできます。たとえば、ボタンをクリックして数値が増加することを確認します。実際には、そのようなコードはほとんどありません。インタラクションのないコードをテストしても、価値は限られます。
テスト対象のコンポーネント
Vitest とその JSDOM 環境を使用して React コンポーネントをテストします。これにより、ブラウザをエミュレートしながら、コマンドラインで Node を使用してテストをすばやく実行できます。
UserList
という名前のこの React コンポーネントは、ネットワークからユーザーのリストを取得し、そのうちの 1 つを選択できるようにします。ユーザーのリストは 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
はグローバルであるため、コードに import
または require
する必要はありません。
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
から取得する必要があります。
ただし、UserRow
自体が複雑なコンポーネントである可能性があります。さらにユーザーデータを取得したり、テストに関連しない副作用が生じたりする場合があるためです。このようなばらつきを取り除くことで、テストの有用性が向上します。特に、テストするコンポーネントがより複雑になり、依存関係が複雑になってきた場合に有用です。
幸い、Vitest を使用すると、テストで直接使用しない場合でも、特定のインポートをモック化できます。これにより、インポートを使用するコードにシンプルなバージョンまたは既知のバージョンが提供されます。
vi.mock('./UserRow.tsx', () => {
return {
UserRow(arg) {
return <>{arg.u.name}</>;
},
}
});
test('render', async () => {
// ...
});
fetch
グローバルのモック化と同様に、これは強力なツールですが、コードに多くの依存関係がある場合は、持続できなくなる可能性があります。繰り返しになりますが、テスト可能なコードを記述することが最善の解決策です。
クリックしてコンテキストを提供する
React や Lit などの他のライブラリには、Context
というコンセプトがあります。サンプルコードには UserContext
が含まれています。これは、ユーザーが選択された場合にメソッドを呼び出します。これは、コールバックが UserList
に直接渡される「プロップ ドリル」の代替手段としてよく見られます。
テストハーネスは UserContext
を提供していません。クリック アクションを追加せずに React テストに追加すると、最悪の場合テストがクラッシュする可能性があります。デフォルト インスタンスが他の場所で指定されている場合は、Google の制御不能な動作(上記の未知の UserRow
と同様)を引き起こす可能性があります。
const c = render(<UserList />);
const chooseButton = await c.getByText(/Choose);
chooseButton.click();
代わりに、コンポーネントのレンダリング時に独自の Context
を指定できます。この例では、Vitest 疑似関数である vi.fn()
のインスタンスを使用します。このインスタンスを使用すると、呼び出しが行われたことと、使用された引数を確認できます。
この例では、これは前述の例でモックされた 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 コンポーネントのテストにどのようなアプローチが使用されましたか?