メインコンテンツまでスキップ

ロジックをフックへ押し出す: テストのしにくさが抽出を決める

カスタムフックへの切り出しは、多いほど良いわけではありません。切り出すたびにファイルが増え、呼ぶ側と呼ばれる側の往復が増えます。

では何を基準に決めるのか。この章では テストの書きにくさを基準にします。03 章で扱った「書きにくさは情報である」を、React の文脈に当てはめます。

この章で学ぶこと

  • 切り出す判断と、切り出さない判断の基準
  • 切り出しても既存のテストが落ちない書き方
  • フックのテストと画面のテストの役割分担
前提知識

この章で扱わないこと

観点参照先
renderHookact の使い方カスタムフック
カスタムフックの命名や設計の原則同上
テストが書きにくいときの 5 つの型 (言語に依らない話)03 テストが設計を圧す

この章の開始時点のコード

10 章で、通信を伴う一覧を作りました。ReservationList の中に、4 つの状態を管理する処理が入っています。

export function ReservationList({ roomId, fetchReservations }: Props) {
const [items, setItems] = useState<Reservation[] | null>(null)
const [error, setError] = useState<Error | null>(null)

useEffect(() => {
let cancelled = false
fetchReservations(roomId)
.then((result) => { if (!cancelled) setItems(result) })
.catch((e) => { if (!cancelled) setError(e) })
return () => { cancelled = true }
}, [roomId, fetchReservations])

// 4 つの状態に応じた描画
}

テストは 09 章と 10 章で書いた 7 本が通っています。

切り出したくなる瞬間

上のコードを見て「フックに切り出したい」と思うのは自然です。ただし、その動機を分けて考えます。

**見た目の理由。**コンポーネントが長い、状態の宣言が並んでいる、useEffect があると読みにくい。これらは切り出す理由として弱いものです。切り出しても行数は減らず、ファイルをまたぐぶん読む手間は増えます。

**テストの理由。**画面を描画せずに確かめたいことが出てきた。これは強い理由です。

いまのところ、後者は起きていません。4 つの状態はすべて画面に出るので、画面のテストで確かめられます。この段階では切り出しません。

切り出さない判断

切り出さない例をもう 1 つ見ます。09 章のフォーム検証です。

const error = end <= start ? '終了は開始より後にしてください' : null

「検証ロジックだから useValidation に切り出そう」と考えたくなりますが、この処理は React の機能を何も使っていません。状態も持たず、副作用もありません。

切り出すなら、フックではなく純粋な関数です。

export function validateRange(start: string, end: string): string | null {
return end <= start ? '終了は開始より後にしてください' : null
}

こうすると、テストは React を経由しません。

it('終了が開始より前ならメッセージを返す', () => {
expect(validateRange('14:00', '13:00')).toBe('終了は開始より後にしてください')
})

**フックにする必要があるのは、React の機能 (状態、副作用、コンテキスト) を使うものだけです。**使っていないものをフックにすると、テストで renderHook が要るようになり、確かめるのが遠回りになります。

切り出す判断

仕様が増えたとします。「読み込みに失敗したら 3 回まで自動で再試行し、間隔を 1 秒・2 秒・4 秒と延ばす」。

これを画面のテストで確かめようとすると、困ります。

  • 再試行の間隔が正しいかは、画面には出ません
  • 3 回で打ち切ることを確かめるには、4 回目が呼ばれないことを見る必要があります
  • タイマーを進めながら、その都度 DOM を確認することになります

画面に出ないものを確かめたいという状況です。ここが切り出す基準です。

export function useReservations(roomId: string, fetchReservations: FetchReservations) {
// 状態と再試行の制御
return { items, error, isLoading, retryCount }
}

フックのテストでは、再試行の回数と間隔を直接見ます。画面のテストでは、成功したときに一覧が出ることだけを見ます。確かめたいものの性質で、テストの置き場所が分かれました。

切り出しても落ちないテスト

ここで確かめたいことがあります。09 章と 10 章で書いた 7 本は、切り出しによって落ちるでしょうか。

落ちません。7 本はすべて、画面に出るものを利用者の手がかり (ラベル、役割、文言) で探しています。状態の持ち方がコンポーネントの中からフックへ移っても、描画される DOM は同じです。

これが 09 章で「内側に触らない」と書いたことの利得です。もしあのとき内部の状態を直接見るテストを書いていたら、切り出しのたびに書き直しが要りました。

**リファクタしてテストが落ちなかったことは、テストの当て先が正しかったという証拠です。**逆に、振る舞いを変えていないのに落ちたら、当て先を見直す合図になります。

フックと画面の役割分担

切り出した後、テストは 2 か所に分かれます。重複させないために線を引きます。

置き場所確かめるもの
フックのテスト画面に出ない制御再試行の回数と間隔、依存が変わったときの再取得
画面のテスト利用者が見るもの一覧が出る、エラーが出る、再試行ボタンがある

**同じことを両方で確かめません。**フックのテストで「エラー時に error が入る」を見たなら、画面のテストで見るのは「エラーが表示される」であって、内部の値ではありません。

両方に同じ検証を置くと、仕様が変わったとき 2 か所を直すことになります。手間が増えるだけでなく、片方を直し忘れたときに矛盾したテストが残ります。

判断の基準

状況判断
React の機能を使っていない純粋な関数にする。フックにしない
確かめたいものが全部画面に出る切り出さない。画面のテストで足りる
画面に出ない制御を確かめたい切り出す
複数の画面で同じ処理を使う切り出す (テストとは別の理由)
コンポーネントが長いそれだけでは理由にならない

最後の行が要点です。長さは分割の理由になりますが、フックにする理由にはなりません。描画の一部を別コンポーネントに分ける方が適切なこともあります。

まとめ

  • フックにするのは React の機能を使うものだけです。使っていないものは純粋な関数にします
  • 確かめたいものが全部画面に出るなら、切り出す必要はありません
  • 画面に出ない制御 (回数、間隔、呼ばれなかったこと) を確かめたくなったときが、切り出す基準です
  • 利用者に見える形でテストを書いてあれば、切り出しても落ちません。落ちないことが当て先の正しさを示します
  • 切り出した後は、フックと画面で確かめるものを分けます。同じことを両方に置きません

次に読む