カスタムフックを切り出してrenderHookでテストする方法

​​React​React Testing Library,TypeScript,Vitest,カスタムフック

コンポーネントの中のロジックをカスタムフックに切り出すと、React Testing Library の renderHook で、表示用のコンポーネントを自分で書かずに、フックだけをテストできます。検索欄のコンポーネントから、入力が止まるのを待つフックを切り出し、renderHook でテストする手順を、TypeScript で書いて確かめました。

間違えやすい書き方を4つ、わざと失敗するテストにして確かめました。戻り値を分割代入してから確かめる、act で包まずに状態を変える、偽のタイマーを act の外で進める、rerender に引数を渡し忘れる、の4つです。それぞれ、Vitest の出力から、そのテストの結果の行だけを抜き出して載せます。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは vitest 5.0.5、jsdom 30.1.3、@testing-library/react 16.3.3、@testing-library/dom 10.4.2、@testing-library/jest-dom 7.0.1、react / react-dom 19.3.1、typescript 7.0.2 です。Vitest は、環境を jsdom にし、setup ファイルの afterEach で cleanup() を呼ぶ設定にしています(Vitest の globals の設定は、既定の false のまま)。

コンポーネントからカスタムフックを切り出す

切り出す前の検索欄は、入力が 300ms 止まったら検索する語を確定し、onSearch を呼びます。入力を待つ処理が、コンポーネントの中にそのまま書かれています。

tsx
import { useEffect, useState } from 'react';

export function SearchBox({ onSearch }: { onSearch: (q: string) => void }) {
  const [text, setText] = useState('');
  const [query, setQuery] = useState('');

  // 入力が 300ms 止まったら、検索する語を確定する
  useEffect(() => {
    const id = setTimeout(() => setQuery(text), 300);
    return () => clearTimeout(id);
  }, [text]);

  useEffect(() => {
    if (query) onSearch(query);
  }, [query, onSearch]);

  return <input aria-label="検索" value={text} onChange={(e) => setText(e.target.value)} />;
}

待つ処理を、useDebouncedValue というフックに移します。React の公式ドキュメントにある、フックの名前の決まりのとおり、名前は use で始め、その次の文字を大文字にします。同じページのカスタムフックを使う場面の説明では、エフェクトを書いたら、カスタムフックに包むと分かりやすくならないかを考えるよう勧めています。

ts
// useDebouncedValue.ts
import { useEffect, useState } from 'react';

// value が delayMs のあいだ変わらなかったら、その値を返す
export function useDebouncedValue<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(id);
  }, [value, delayMs]);
  return debounced;
}

コンポーネントの側では、待つ処理がフックを呼ぶ1行になります(import は省いています)。

tsx
export function SearchBox({ onSearch }: { onSearch: (q: string) => void }) {
  const [text, setText] = useState('');
  const query = useDebouncedValue(text, 300);

  useEffect(() => {
    if (query) onSearch(query);
  }, [query, onSearch]);

  return <input aria-label="検索" value={text} onChange={(e) => setText(e.target.value)} />;
}

renderHook の基本:result.current を act のあとに読む

フックのルールのとおり、フックは、コンポーネントかカスタムフックの中で呼ぶ決まりです。そのため renderHook は、渡したコールバックを呼ぶだけのテスト用のコンポーネントを作って描画します。戻り値は result.current に入ります。

まずは、上限のある数を数える useCounter で形を確かめます。

ts
// useCounter.ts
import { useCallback, useState } from 'react';

export function useCounter({ initial = 0, max }: { initial?: number; max: number }) {
  const [count, setCount] = useState(initial);
  const increment = useCallback(() => setCount((c) => Math.min(c + 1, max)), [max]);
  const reset = useCallback(() => setCount(initial), [initial]);
  return { count, increment, reset };
}

状態を変える関数は act の中で呼び、そのあとで result.current を読みます。React の act のリファレンスでは、act は await と async 関数で使うよう勧められているので、await act(async () => { ... }) の形にしています。

ts
import { act, renderHook } from '@testing-library/react';
import { expect, test } from 'vitest';
import { useCounter } from './useCounter';

test('increment で1つ増える', async () => {
  const { result } = renderHook(() => useCounter({ max: 3 }));
  expect(result.current.count).toBe(0);

  await act(async () => {
    result.current.increment();
  });
  expect(result.current.count).toBe(1);
});

test('max より大きくならない', async () => {
  const { result } = renderHook(() => useCounter({ max: 3 }));
  await act(async () => {
    for (let i = 0; i < 5; i++) result.current.increment();
  });
  expect(result.current.count).toBe(3);
});

戻り値を分割代入すると、古い値のまま

次のように、result.current から先に値を取り出しておくと、テストは失敗しました(import は省いています)。

ts
test('落とし穴1:result.current を分割代入してから確かめる', async () => {
  const { result } = renderHook(() => useCounter({ max: 3 }));
  const { count, increment } = result.current;
  await act(async () => {
    increment();
  });
  expect(count).toBe(1);
});
text
 × src/useCounter.pitfall.test.ts > 落とし穴1:result.current を分割代入してから確かめる 24ms
   → expected +0 to be 1 // Object.is equality

React Testing Library の renderHook の戻り値の説明によると、result は、コールバックの戻り値のうち、最後にコミットされたものを持つ ref のようなものです。インストールしたパッケージの中身を見ると、テスト用のコンポーネントは、描画のたびに useEffect の中で result.current を入れ直していました。描画し直すと result.current は新しい戻り値に変わりますが、先に取り出した count は前の戻り値の 0 のままです(下の図)。

renderHook の仕組みの図。テスト用のコンポーネントが描画のたびにコールバックを呼び、コミットのあとに useEffect で result.current を入れ直す。act の中で increment を呼んだあと、result.current は新しい戻り値(count: 1)を指し、分割代入した count は前の戻り値(count: 0)のまま
result.current は新しい戻り値に入れ替わるが、分割代入した値は前のまま

値を確かめるときは、毎回 result.current から読みます。

act で包まないと、更新がまだ反映されていない

increment() を act で包まずに呼ぶと、こちらも失敗しました(import は省いています)。

ts
test('落とし穴2:act で包まずに increment を呼ぶ', async () => {
  const { result } = renderHook(() => useCounter({ max: 3 }));
  result.current.increment();
  expect(result.current.count).toBe(1);
});
text
 × src/useCounter.pitfall.test.ts > 落とし穴2:act で包まずに increment を呼ぶ 4ms
   → expected +0 to be 1 // Object.is equality

increment() を呼んだ直後の count は 0 でした。waitFor で待つと 1 になったので、更新はあとから反映されていました。act のリファレンスによると、act は、中で起きた更新を処理して DOM に反映してから、確かめられるようにするものです。

補足

act で包まずに increment() を呼び、waitFor で待つテストを流しました。globals が既定の false の設定では、React の警告は出ませんでした。setup ファイルを外して globals: true にした設定では、「An update to TestComponent inside a test was not wrapped in act(…).」という警告が出ました。

act のリファレンスのトラブルシューティングには、act を使うにはテストの環境で IS_REACT_ACT_ENVIRONMENT を true にする必要があり、React Testing Library では設定済みだと書かれています。

ところが、インストールしたパッケージの中身を見ると、React Testing Library の act は、実行しているあいだだけ IS_REACT_ACT_ENVIRONMENT を true にし、終わると元に戻していました。テスト全体を通して true にする処理は、beforeAll と afterAll がグローバルにあるときだけ動きます。Vitest の globals が false だと、この処理が動きません。そのため、act の外での更新に警告が出ませんでした。globals を変えずに警告を出したいときは、setup ファイルで自分で設定します。

ts
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

declare global {
  var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}

// act の外で state が変わったら、React に警告を出させる
globalThis.IS_REACT_ACT_ENVIRONMENT = true;

afterEach(() => {
  cleanup();
});

この setup ファイルで、上の囲みと同じ確認用のテストを流すと、globals: true のときと同じ警告が出ました。declare global がないと、tsc が「Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature.」というエラーを出しました。

引数を変える:initialProps と rerender

useDebouncedValue のように引数で動きが変わるフックは、initialProps に最初の引数を渡し、rerender で新しい引数を渡します。renderHook の initialProps の説明のとおり、コールバックは initialProps を受け取ります。TypeScript では、initialProps から引数の型が決まりました。

タイマーは、Vitest の vi.useFakeTimers で偽物に替え、vi.advanceTimersByTime() で進めます。タイマーを進めると状態が変わるので、ここも act で包みます。

ts
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { useDebouncedValue } from './useDebouncedValue';

beforeEach(() => {
  vi.useFakeTimers();
});
afterEach(() => {
  vi.useRealTimers();
});

test('300ms たつまでは前の値のまま', async () => {
  const { result, rerender } = renderHook(
    ({ value }) => useDebouncedValue(value, 300),
    { initialProps: { value: 'ね' } },
  );
  expect(result.current).toBe('ね');

  rerender({ value: 'ねこ' });
  expect(result.current).toBe('ね');

  await act(async () => {
    vi.advanceTimersByTime(299);
  });
  expect(result.current).toBe('ね');

  await act(async () => {
    vi.advanceTimersByTime(1);
  });
  expect(result.current).toBe('ねこ');
});

299ms では前の値のままで、300ms で新しい値になりました。コードは省きますが、200ms あけて値を2回変えたテストでは、最後に変えてから 300ms たつまで、最初の値のままでした。

偽のタイマーを act の外で進めると、前の値のまま

vi.advanceTimersByTime(300) を act で包まずに呼ぶと、失敗しました。このテストも、beforeEach で vi.useFakeTimers() を呼ぶファイルに書いています(import は省いています)。

ts
test('落とし穴3:advanceTimersByTime を act で包まない', async () => {
  const { result, rerender } = renderHook(
    ({ value }) => useDebouncedValue(value, 300),
    { initialProps: { value: 'ね' } },
  );
  rerender({ value: 'ねこ' });
  vi.advanceTimersByTime(300);
  expect(result.current).toBe('ねこ');
});
text
 × src/useDebouncedValue.pitfall.test.ts > 落とし穴3:advanceTimersByTime を act で包まない 34ms
   → expected 'ね' to be 'ねこ' // Object.is equality

タイマーを進める処理も、上の 299ms と 300ms のテストのように act で包みます。

rerender() に引数を渡さないと、undefined になる

renderHook の initialProps の説明には、引数なしで rerender() を呼ぶと、initialProps は渡されないと書かれています。引数を省ける形のコールバックで試すと、コールバックが受け取る引数が undefined になり、300ms たったあとの result.current も、前の値ではなく undefined になりました。このテストも、beforeEach で vi.useFakeTimers() を呼ぶファイルに書いています(import は省いています)。

ts
test('落とし穴4:rerender に props を渡し忘れる', async () => {
  const { result, rerender } = renderHook(
    (props?: { value: string }) => useDebouncedValue(props?.value, 300),
    { initialProps: { value: 'ね' } },
  );
  rerender();
  await act(async () => {
    vi.advanceTimersByTime(300);
  });
  expect(result.current).toBe('ね');
});
text
 × src/useDebouncedValue.pitfall.test.ts > 落とし穴4:rerender に props を渡し忘れる 8ms
   → expected undefined to be 'ね' // Object.is equality

rerender には、変えない引数も含めて、毎回すべての引数を渡します。

後片づけを確かめる:unmount

renderHook が返す unmount で、テスト用のコンポーネントを外せます。エフェクトの後片づけ(ここでは clearTimeout)が動くかを、vi.getTimerCount で待っているタイマーの数を数えて確かめました。このテストも、beforeEach で vi.useFakeTimers() を呼ぶファイルに書いています(import は省いています)。

ts
test('unmount するとタイマーが片づく', async () => {
  const { rerender, unmount } = renderHook(
    ({ value }) => useDebouncedValue(value, 300),
    { initialProps: { value: 'ね' } },
  );
  rerender({ value: 'ねこ' });
  expect(vi.getTimerCount()).toBe(1);

  unmount();
  expect(vi.getTimerCount()).toBe(0);
});

コンテキストを読むフック:wrapper で Provider を渡す

Provider が渡す値を読むフックは、テストでも Provider で包む必要があります。カートの中身を読む useCart は、Provider がないと値が null になるので、そのときはエラーを投げるようにしました。

tsx
// cart.tsx
import { createContext, useContext, useState, type ReactNode } from 'react';

type Cart = { items: string[]; add: (item: string) => void };

const CartContext = createContext<Cart | null>(null);

export function CartProvider({ initialItems = [], children }: { initialItems?: string[]; children: ReactNode }) {
  const [items, setItems] = useState(initialItems);
  const add = (item: string) => setItems((prev) => [...prev, item]);
  return <CartContext value={{ items, add }}>{children}</CartContext>;
}

export function useCart(): Cart {
  const cart = useContext(CartContext);
  if (!cart) throw new Error('useCart は CartProvider の中で使ってください');
  return cart;
}

renderHook の wrapper に、Provider で包むコンポーネントを渡します。render の wrapper の説明にあるとおり、wrapper に渡したコンポーネントは、テストするものの外側に描画されます。次のテストでは、ReactNode・CartProvider・useCart などの import を省いています。

tsx
test('wrapper で CartProvider を渡すと、カートに入れられる', async () => {
  const wrapper = ({ children }: { children: ReactNode }) => <CartProvider>{children}</CartProvider>;
  const { result } = renderHook(() => useCart(), { wrapper });

  await act(async () => {
    result.current.add('かつおぶし');
  });
  expect(result.current.items).toEqual(['かつおぶし']);
});

initialProps は wrapper には渡されません(initialProps の説明の注意書き)。Provider に値を渡したいときは、ドキュメントの例と同じく、wrapper を作る関数を用意します(import は省いています)。

tsx
test('wrapper に値を渡したいときは、wrapper を作る関数を用意する', async () => {
  const createWrapper = (initialItems: string[]) =>
    function Wrapper({ children }: { children: ReactNode }) {
      return <CartProvider initialItems={initialItems}>{children}</CartProvider>;
    };
  const { result } = renderHook(() => useCart(), { wrapper: createWrapper(['ねこじゃらし']) });
  expect(result.current.items).toEqual(['ねこじゃらし']);
});

Provider がないときのエラーは、renderHook の呼び出しから投げられました。expect(() => renderHook(() => useCart())).toThrow('useCart は CartProvider の中で使ってください') で確かめられます。

renderHook と render の使い分け

React Testing Library の renderHook の説明では、renderHook は主にフックを公開するライブラリ向けで、ふつうは render を使うよう勧めています。自分でテスト用のコンポーネントを書いて render するほうが、テストしたいものが renderHook という抽象化の陰に隠れず、読みやすく壊れにくいテストになる、という理由です。

筆者は、フックを切り出したあとも、コンポーネントのテストは render で書いておくことにしました。次のテストは、切り出す前と切り出したあとの検索欄の両方で通りました。useDebouncedValue のテストと同じく、beforeEach で vi.useFakeTimers() を呼んでいます(import と beforeEach・afterEach は省いています)。

tsx
test('入力が 300ms 止まったら、1回だけ検索する', async () => {
  const onSearch = vi.fn();
  render(<SearchBox onSearch={onSearch} />);
  const input = screen.getByLabelText('検索');

  fireEvent.change(input, { target: { value: 'ね' } });
  fireEvent.change(input, { target: { value: 'ねこ' } });
  await act(async () => {
    vi.advanceTimersByTime(300);
  });
  expect(onSearch).toHaveBeenCalledTimes(1);
  expect(onSearch).toHaveBeenCalledWith('ねこ');
});
ポイント

筆者は、この記事のように、フックの細かい動き(境目の 299ms と 300ms、上限、後片づけ)を確かめるときに renderHook を使い、利用者から見える動きは render でコンポーネントを描画して確かめる、と分けました。

なお、以前は @testing-library/react-hooks という別のパッケージが renderHook を提供していました。そのパッケージの README には、React 18 からは @testing-library/react の renderHook に置き換えるよう書かれています。

失敗する書き方の早見表

書き方 結果 直し方
result.current を分割代入して値を確かめる 前の値のまま 値は毎回 result.current から読む
状態を変える関数を act の外で呼ぶ 直後はまだ前の値 await act(async () => { ... }) で包む
偽のタイマーを act の外で進める 前の値のまま vi.advanceTimersByTime() を act で包む
rerender() を引数なしで呼ぶ 引数が undefined になる すべての引数を毎回渡す

​Posted by 86