Skip to main content

古典学派でのReact API呼び出しhooksテスト戦略(モック境界とテストケース数)

TL;DR​

  • 古典学派ではプロセス外依存(HTTP境界)だけをモック化する。hooks内部の関数・useQuery等はプロダクションコードのまま動かす。具体的にはMSWでネットワーク層をインターセプトする。
  • テストケース数は「APIの返却パターン数」ではなく「hooks側の観察可能な振る舞いの分岐数」で決まる。hooks内部に分岐がなければ、異なるレスポンスパターンでも1テストで代表させて十分。
  • 「どのレスポンスパターンでhooksの振る舞いが変わるか」を基準にテストを分け、「同じ振る舞いになるレスポンスパターン」は同値クラスとしてまとめる。

このドキュメントの射程​

React で API を呼び出すカスタムhooks(例: useUserList)をテストするとき、古典学派(デトロイト学派 / Khorikov『単体テストの考え方/使い方』)の原則に沿ってどう設計するか。対象は主にTanStack Query + MSW環境だが、fetch/axios + useEffect構成でも考え方は同じ。

モック境界: 何をモック化し、何をしないか​

古典学派の原則​

  • モック化する: テストケース間で共有されるプロセス外依存(= HTTP API)
  • モック化しない: プライベート依存(axios, fetch, useQuery, APIクライアント関数など hooks 内部の全て)

ロンドン学派寄り(避けたい例)​

// ❌ 実装の詳細に結びつく
jest.mock('axios');
(axios.get as jest.Mock).mockResolvedValue({ data: users });

// ❌ useQuery自体をモック化
jest.mock('@tanstack/react-query');

axiosからfetchへの変更、APIクライアント関数の抽出など、振る舞いに影響しないリファクタリングでテストが壊れる。

古典学派寄り(推奨)​

// ✅ HTTP境界だけをMSWでモック化
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
http.get('/api/users', () =>
HttpResponse.json([{ id: 1, name: 'Alice' }])
)
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

hooks内部でaxiosを使おうがfetchを使おうが、TanStack Queryに乗せ換えようが、振る舞いが同じならテストは壊れない。

TanStack Query使用時の最低限の設定​

const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity },
},
});

const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={createTestQueryClient()}>
{children}
</QueryClientProvider>
);

retry: false は必須。デフォルトだとエラーケースで3回リトライしてテストが遅くなる。

テストケース数: 何を基準に決めるか​

原則​

テストケース数を決めるのは APIが何パターンのレスポンスを返しうるか ではなく、hooks側のコードに振る舞いの分岐がいくつあるか。

これは2つの原則の組み合わせ:

  • 古典学派: 「観察可能な振る舞い」を検証する。実装の詳細(どのステータスコードが来たか)ではなく、hooks側の出力(data, isError, errorMessage等)で判断する
  • 同値分割法(ブラックボックステスト技法): 同じ出力をもたらす入力群は1つの同値クラスとして代表値1つで検証すれば十分

※ 同値分割法は古典学派固有の技法ではなく、より古いブラックボックステストの技法。ただし古典学派はブラックボックステスト寄りの立場なので、自然に組み合わさって使われる。

パターン別: hooks内に分岐がある場合​

export function useUserList() {
const query = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
const code = query.data?.resultCode;

return {
...query,
errorType:
code === 'E001' ? 'validation' :
code === 'E401' ? 'auth' :
code === 'E404' ? 'notFound' :
code?.startsWith('E') ? 'unknown' : null,
};
}

→ hooks内に4分岐あるので 4ケース + 正常系1ケース 書く。各分岐が別々の「観察可能な振る舞い」を持つため。

test('正常時にdataが返る', ...)
test('E001のときerrorTypeがvalidationになる', ...)
test('E401のときerrorTypeがauthになる', ...)
test('E404のときerrorTypeがnotFoundになる', ...)
test('その他Eコード(E500等)のときerrorTypeがunknownになる', ...) // 代表1つ

E500とE999は同じ分岐(startsWith('E'))に入るので、代表値1つでOK。

パターン別: hooks内に分岐がない場合(pass through)​

export function useUserList() {
const query = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
return {
data: query.data?.data,
resultCode: query.data?.resultCode,
isLoading: query.isLoading,
};
}

→ hooks内に分岐がないので 正常系1ケース + エラー系の代表1ケース で十分。

パターン別: 分岐がコンポーネント側にある場合​

hooksはresultCodeを露出するだけで、コンポーネント側でResultCodeごとに表示を分岐するケース。

  • hooksテスト: 「エラー時にresultCodeが入る」を代表1つ
  • コンポーネントテスト: コンポーネント内の分岐数分テストを書く

分岐が存在するレイヤーでテストを書く。

判断の簡易ルール​

テスト対象のhooksのコード内に、そのレスポンスパターンで分岐する if / 三項演算子 / 派生state があるか?

  • ある → その分岐数分テストを書く
  • ない → 既存の同値クラスに吸収(代表1つで十分)

まとめ​

古典学派でReactのAPI呼び出しhooksをテストするときは「HTTP境界だけMSWでモック化、テストケース数はhooks側の振る舞い分岐で決める」。APIが返すレスポンスのバリエーションの多さ自体はテストケース数に影響せず、hooks/コンポーネントのどこに分岐コードがあるかが全てを決める。

参考​