🎄

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

2024/12/19に公開

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

runRenderHook() につきまとう副作用の取り扱いとトラブル

2年前のカレンダーでは、runRenderHook()という実例を紹介し、@testing-library/reactrenderHook()活用でTypeScriptの工夫をお見せしました。しかし、あれから実務で2年ほど運用を続けるといくつか課題が浮き彫りになりました。

renderHook()の戻り値で得られるresult.currentact()と組み合わせてテストシナリオを記述していく中で、let [resultbeforeEach()で格納し](https://zenn.dev/okunokentaro/articles/01gm87etnezh0fadckbpmy6evc#runrenderhook())、その後act()呼び出しで状態を更新したつもりが、result.currentが古いままだったり、テスト実装側のミスで実は期待通り動いていなかったりということが起こりました。これは、resultだけでなくcurrentの参照を別のconstで格納してしまったり、そういった処理をしている間にresult以下の値が置き換わっていたりしたという手違いでした。最新のcurrentと最新ではないcurrent変数を両方持ってしまうという、副作用を扱った処理につきものの「冷静になれば間違っていることがわかる」というトラブルでした。

このような状況でテストがレッドになっても、それが実装上の問題かテストコード側の問題か判別しづらく、開発者がHooksのテストを書く際に疑心暗鬼になり、メンテナンス性・信頼性が低下してしまいました。コードレビューでも、トラブル以後はresult.currentの変数の扱いを慎重に確認し、違反した格納がないか確認するという、自動化しているにも関わらず人の目でも丁寧にチェックしなければならないという面倒に繋がっていました。

そうした不安定さを取り除き、result.currentを常に最新状態で、安心して取り扱えるテスト環境の構築が望まれました。

hooksTestingToolsFactory()の導入

そこで用意したのがhooksTestingToolsFactory()です。renderHook()の結果を隠蔽しact()expect()をラップしたユーティリティを提供することで、毎回最新のresult.currentを確信して得られる仕組みを提供します。

import { act, renderHook } from "@testing-library/react";
import { expect } from "vitest";

import { exists } from './exists';

type Render<R, P> = Parameters<typeof renderHook<R, P>>[0];
type Current<R, P> = ReturnType<typeof renderHook<R, P>>["result"]["current"];
type Callback<R, P> = (v: Current<R, P>) => void;

type ExpectCallback<R, P, V> = (v: Current<R, P>) => V;

type ExpectInterface<R, P, V> = {
  (cb: ExpectCallback<R, P, V>): ReturnType<typeof expect<V>>;
  soft: (cb: ExpectCallback<R, P, V>) => ReturnType<typeof expect<V>>;
};

type Return<R, P, V> = {
  act: (cb: Callback<R, P>) => void;
  expect: ExpectInterface<R, P, V>;
};

export function hooksTestingToolsFactory<R, P, V>(
  render: Render<R, P>,
  wrapper?: NonNullable<Parameters<typeof renderHook<R, P>>[1]>["wrapper"],
): Return<R, P, V> {
  const rendered = renderHook<R, P>(
    render,
    exists(wrapper) ? { wrapper } : void 0,
  );

  function wrappedExpect(
    cb: ExpectCallback<R, P, V>,
  ): ReturnType<typeof expect<V>> {
    return expect<V>(cb(rendered.result.current));
  }

  wrappedExpect.soft = (
    cb: ExpectCallback<R, P, V>,
  ): ReturnType<typeof expect.soft<V>> => {
    return expect.soft<V>(cb(rendered.result.current));
  };

  return {
    act: (cb) => {
      act(() => cb(rendered.result.current));
    },
    expect: wrappedExpect,
  } as const;
}

export type HooksTestingTools<T extends (...args: any) => any> = ReturnType<
  typeof hooksTestingToolsFactory<ReturnType<T>, Parameters<T>, unknown>
>;

このサンプルコードに出てくるexists()は2年前に紹介したものです。

そして、このユーティリティ関数を使ってテストを書き直すと次のようになります。以下のコードは、筆者が技術顧問を務めます株式会社トレタにおける、実際の業務で使用しているテストから一部のケースのみを抜粋した実物です。掲載許可をいただいております。

function useRender(): typeof ret {
  const ret = {
    addOrderItem: useAddOrderItem(),
    increaseOrderItem: useIncreaseOrderItem(),
    totalQuantityInOrderList: useTotalQuantityInOrderList(locationId),
  } as const;
  return ret;
}

describe("increaseOrderItem()", () => {
  let t: HooksTestingTools<typeof useRender>;

  beforeEach(() => {
    t = hooksTestingToolsFactory(useRender);
  });

  test("totalQuantityInOrderList が 1 つ増加", () => {
    t.act((v) => v.addOrderItem({ ...item1 }, 1));
    t.act((v) => v.increaseOrderItem({ ...item1 }));

    t.expect((v) => v.totalQuantityInOrderList).toEqual(2);
  });

  test("オーダーリストに存在しない場合は例外となる", () => {
    t.expect((v) => () => v.increaseOrderItem({ ...item1 })).toThrow(
      PreconditionError,
    );
  });
});

これで事前にtを初期化しておくだけで、あとはt.act(...)t.expect(...)を呼ぶたびに、そのコールバック引数で常にrendered.result.currentを得られるため、副作用を失念して複数の参照を混在させてしまうという事故は、以後全く起きなくなりました。テストを書く側は最新状態を取り違える心配から解放され、これでReact Hooksのテストをより気楽に書けます。

実務発のニーズとそれを解決させる工夫の匙加減

hooksTestingToolsFactory()は「副作用状態を持つHooksをテストする際の取り違えを関数で防げないだろうか」という実務上のニーズから生まれました。今回の記事では、特にTypeScriptの新機能を紹介するものではないのですが、Parameters, ReturnType, NonNullableなどのユーティリティ型を組み合わせたりNonNullable<Parameters<typeof renderHook<R, P>>[1]>["wrapper"]のように外部.d.tsファイルの定義を引用するような工夫を組み合わせることで、様々なHooksをテスト上で適切に扱えるユーティリティを実現しています。

この記事で紹介したテクニックは、あくまで「ちょっとした支援」を目指したものです。工夫しすぎると独自フレームワーク化してしまい、他の開発者にとって手に負えないものとなってしまいます。 どこまでユーティリティ作りにこだわるかというバランス感覚は実務上とても大切なポイントです。こういったユーティリティは実装中はとても気持ちよくなってしまうものですから、開発者の自己満足で終わらないように「いま何が問題で、何を解決したいのか」「ユーティリティを導入しない場合なぜ解決が困難なのか」といった議論をチーム内で丁寧に重ねます。

たとえばt.act()t.expect()といった書き方は、既存のユーティリティ導入前のVitestのテストコードからすばやく移行できるかという移植性から生まれた発想で、万が一やめたくなった場合にもt.を除去すれば戻せるという加減でインタフェースを決定しています。また、Array.prototype.map()Array.prototype.filter()などのarray.map(v => ...)に出てくるような引数vを、外部スコープの変数に格納したりすることは一般論として不自然だろうという発想や共通認識なども揃えた上で、t.act(v => ...)のような形で引数vで最新の状態が得られるのであれば、それをわざわざ別の変数で保持するようなプログラムは自然と書かなくなるはずである、といった議論や認識形成を進めました。作って導入して終わりではなく、ユーティリティを作るべきかの議論や、ユーティリティを作ったあとのチーム内での移行の促進は手厚くフォローをいれるようにしています。

明日は『noUncheckedIndexedAccess』

本日は実務的な工夫の組み合わせとして「hooksTestingTools()」を紹介しました。明日は「noUncheckedIndexedAccess」を紹介します。それではまた。

Discussion