🎄

実例 runRenderHook() / TypeScript一人カレンダー

2022/12/15に公開

こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2022の4日目です。昨日は『inferと実例 UnArray<T>』を紹介しました。

テストでちょっと困る型の扱い

本記事は特にReact + Recoilを使っている方向けになってしまうことを断っておく必要があるのですが、本日は以前紹介したReturnType<T>を使ってちょっとした便利関数を作ってみます。

Reactでは、単体テストを実施する際にreact-testing-libraryを使う方が多いと思います。このライブラリはReactの公式のドキュメントで紹介されているもので、筆者の担当する案件でもこれを採用しています。

このライブラリの中にはrenderHook()という関数があり、この関数を使うことでReactのCustom Hookの単体テストが書きやすくなります。

次のコードは、関わった案件のコードをそのまま掲載することはできないため雰囲気を似せて新規に書いたものです。状況としては「通販サイトの買い物カゴを実装している」と想定してください。買い物カゴの状態管理にはRecoilを採用しています。

renderHook(
  () => ({
    addItem: useAddItem(),
    cart: useCart(),
    cartLength: useCartLength(),
  }),
  { wrapper: ({ children }) => <RecoilRoot>{children}</RecoilRoot> }
);

では、この処理を使ってテストを書いてみましょう。テストはJestで書いていきます。

useAddItem() Hookから得られる関数addItem()に商品オブジェクトを渡すと、その商品が買い物カゴに追加され、件数が増えることを確認したいとします。Hookの内部実装は今回の記事に無関係なので「そういうことができる関数」くらいに思っておいてください。

test("買い物カゴに商品が追加される", async () => {
  const { result } = renderHook(
    () => ({
      addItem: useAddItem(),
      cart: useCart(),
      cartLength: useCartLength(),
    }),
    { wrapper: ({ children }) => <RecoilRoot>{children}</RecoilRoot> }
  );

  await act(() => result.current.addItem(item1));
  expect(result.current.cartLength).toEqual(1);
});

できました。では次のテストです。

test("同じ商品を追加した場合、商品の個数が増え、買い物カゴ全体の件数は1のままである", async () => {
  const { result } = renderHook(
    () => ({
      addItem: useAddItem(),
      cart: useCart(),
      cartLength: useCartLength(),
    }),
    { wrapper: ({ children }) => <RecoilRoot>{children}</RecoilRoot> }
  );

  await act(() => result.current.addItem(item1));
  await act(() => result.current.addItem(item1));
  expect(result.current.cartLength).toEqual(1);
});

このテストもコピペしてすぐに書けました!では次のテストです。

test("異なる商品を追加したとき、買い物カゴ内の商品件数が増える", async () => {
  const { result } = renderHook(
    () => ({
      addItem: useAddItem(),
      cart: useCart(),
      cartLength: useCartLength(),
    }),
    { wrapper: ({ children }) => <RecoilRoot>{children}</RecoilRoot> }
  );

  await act(() => result.current.addItem(item1));
  await act(() => result.current.addItem(item2));
  expect(result.current.cartLength).toEqual(2);
});

このテストも前のテストのコピペだから簡単ですね!よし、一連のテストが完成しました。仕上がりを見てみましょう。

describe("useAddItem()", () => {
  test("買い物カゴに商品が追加される", async () => {
    const { result } = renderHook(
      () => ({
        addItem: useAddItem(),
        cart: useCart(),
        cartLength: useCartLength(),
      }),
      { wrapper: ({ children }) => <RecoilRoot>{children}</RecoilRoot> }
    );

    await act(() => result.current.addItem(item1));
    expect(result.current.cartLength).toEqual(1);
  });

  test("同じ商品を追加した場合、商品の個数が増え、買い物カゴ全体の件数は1のままである", async () => {
    const { result } = renderHook(
      () => ({
        addItem: useAddItem(),
        cart: useCart(),
        cartLength: useCartLength(),
      }),
      { wrapper: ({ children }) => <RecoilRoot>{children}</RecoilRoot> }
    );

    await act(() => result.current.addItem(item1));
    await act(() => result.current.addItem(item1));
    expect(result.current.cartLength).toEqual(1);
  });

  test("異なる商品を追加したとき、買い物カゴ内の商品件数が増える", async () => {
    const { result } = renderHook(
      () => ({
        addItem: useAddItem(),
        cart: useCart(),
        cartLength: useCartLength(),
      }),
      { wrapper: ({ children }) => <RecoilRoot>{children}</RecoilRoot> }
    );

    await act(() => result.current.addItem(item1));
    await act(() => result.current.addItem(item2));
    expect(result.current.cartLength).toEqual(2);
  });
});

うーん?テストは書けたけど

ちょっとコードが長くなりました。見ての通りconst { result } = renderHook(の周りの繰り返しが目立ってかさばっています。こんなに何回も書くとDRYに違反しているだけでなく、そもそもテストとしても長くて読みづらいです。ではbeforeEach()にまとめてしまいましょう。

describe("useAddItem()", () => {
  beforeEach(() => {
    const { result } = renderHook(
      () => ({
        addItem: useAddItem(),
        cart: useCart(),
        cartLength: useCartLength(),
      }),
      { wrapper: ({ children }) => <RecoilRoot>{children}</RecoilRoot> }
    );
  });

  test("買い物カゴに商品が追加される", async () => {
    await act(() => result.current.addItem(item1));
    expect(result.current.cartLength).toEqual(1);
  });

  test("同じ商品を追加した場合、商品の個数が増え、買い物カゴ全体の件数は1のままである", async () => {
    await act(() => result.current.addItem(item1));
    await act(() => result.current.addItem(item1));
    expect(result.current.cartLength).toEqual(1);
  });

  test("異なる商品を追加したとき、買い物カゴ内の商品件数が増える", async () => {
    await act(() => result.current.addItem(item1));
    await act(() => result.current.addItem(item2));
    expect(result.current.cartLength).toEqual(2);
  });
});

とてもスッキリしました!いや…よくみるとbeforeEach()の中でconst宣言をするわけにはいきません。このままではテスト関数のスコープ内でresult変数が宣言されていないことになります。ではletで…。

describe("useAddItem()", () => {
  let result;

  beforeEach(() => {
    ({ result } = renderHook(
      () => ({
        addItem: useAddItem(),
        cart: useCart(),
        cartLength: useCartLength(),
      }),
      { wrapper: ({ children }) => <RecoilRoot>{children}</RecoilRoot> }
    ));
  });

  /* 略 */

});

いや、こう書いてしまうとlet resultの変数の型が求まりません。TS7034: Variable 'result' implicitly has type 'any' in some locations where its type cannot be determined.というエラーになってしまいます。

となると、renderHook()の型定義を見に行ってそれをコピペしてきましょうか。引用は本日時点のmain branchです。

https://github.com/testing-library/react-testing-library/blob/c43512a9271f5738496a3ed49aed7e3e9dad071c/types/index.d.ts#L101

使わないプロパティも多いし{ result: { current: ここ }}の型も書かないといけなくてやることが増えてしまいました。さてどうしたもんでしょう。

runRenderHook()

ここでReturnType<T>の存在を思い出してください!

何らかの関数を実装し、その関数の戻り値の型を得られるReturnType<T>を使えば、わざわざガッツリと型アノテーションを書かなくて済むかもしれません。そこでrenderHook()をそのまま使うのではなく「renderHook()を実行させる関数」としてrunRenderHook()を実装してみることにします。出来上がったものが次のコードです。

describe("useAddItem()", () => {
  function runRenderHook() /* inferred */ {
    return renderHook(
      () => ({
        addItem: useAddItem(),
        cart: useCart(),
        cartLength: useCartLength(),
      }),
      { wrapper: ({ children }) => <RecoilRoot>{children}</RecoilRoot> }
    );
  }

  let result: ReturnType<typeof runRenderHook>["result"];

  beforeEach(() => {
    ({ result } = runRenderHook());
  });

  /* 略 */
});

let result: ReturnType<typeof runRenderHook>["result"];と書いたことで、型推論を諦めることなくlet宣言に型アノテーションをつけることができました。こうすることでもうlet result: any;と書く必要はありません。

TypeScriptのtypeofオペレータが強力なのは、こうしたテストコードの途中に作ったような関数からでも、全く問題なく型情報を得られる点です。別に外部からimportしてきたものには限っていません。すぐ上の関数から型を取ってこられます。そうすることで型を推論させ、推論結果をアノテーションに記述することができます。

戻り型アノテーションについて注意

ここで注意点として、関数には「原則として」戻り型アノテーションを付与するべきという点です。ここでいうとfunction runRenderHook() {ではなくfunction runRenderHook(): なんらか {を書くことがTypeScriptプログラミングをする上で好ましいということです。これは「うっかり開発者の意図とは異なって推論されてしまう」ことを防ぐ目的があります。

戻り型アノテーションを省略するとどうなるか、みてみましょう。

TypeScriptでは、if文などの分岐によってreturn文が関数内に複数存在するときは、その関数は複数の型を返しうるという推論をしてしまいます。例えば次のような関数は、string型の値を返すかもしれないし、number型の値を返すかもしれません。

function randomExample() {
  const n = Math.random();
  if (n < 0.5) {
    return n.toString();
  }
  return n;
}

これはかなり極端な例ですが「意図的にstring | number型にしたいのか、それともうっかりstring型に統一したいのに.toString()を呼び忘れたのか」は実装者にしかわからない情報となってしまいます。そのため次のように書くべきです。

function randomExample(): string | number {
}
function randomExample(): string {
} // この場合 return n; は number を返しているのでエラー

話を戻しましょう。「原則的には」型アノテーションは書いておくべきです。しかし今回は型アノテーションを書くことを省略したくてReturnType<T>を採用したのでした。ということで、ここは「書き忘れていない」ことを主張するのがよいです。

これは特に作法はありませんが、筆者の案件では/* inferred */を書き込むようにしています。

describe("useAddItem()", () => {
  /* eslint-disable-next-line @typescript-eslint/explicit-function-return-type */
  function runRenderHook() /* inferred */ {
    //
  }
});

こうすることでここは推論させたいためあえてこうしている、という表明をします。eslintのexplicit-function-return-typeルールに違反となる方も、筆者も含め多いと思われますが、ここではあえてeslint-disable-next-line扱いにしています。プロダクションコードではあまりやらないほうがよいでしょう。テストをシンプルに、かつメンテナンスしやすく書くための折衷案という解釈です。

明日は『Parameters<T>, ConstructorParameters<T>

本日はReturnType<T>を使ってちょっと不便な状況を乗り越えるという話題でした。TypeScript上達のコツは、諦めてanyを使わずに型推論をさせてみることです。絶対にanyを使ってはなりません。このアドベントカレンダーで紹介するテクニックを読んで、あの手この手で徹底的に推論させましょう。

本日は以上です。明日Parameters<T>, ConstructorParameters<T>について。それではまた。

Discussion