ロジックをフックへ押し出す: テストのしにくさが抽出を決める
カスタムフックへの切り出しは、多いほど良いわけではありません。切り出すたびにファイルが増え、呼ぶ側と呼ばれる側の往復が増えます。
では何を基準に決めるのか。この章では テストの書きにくさを基準にします。03 章で扱った「書きにくさは情報である」を、React の文脈に当てはめます。
この章で学ぶこと
- 切り出す判断と、切り出さない判断の基準
- 切り出しても既存のテストが落ちない書き方
- フックのテストと画面のテストの役割分担
09 振る舞いをテストする と 10 非同期とネットワーク境界 で作ったものを使います。
この章で扱わないこと
| 観点 | 参照先 |
|---|---|
renderHook と act の使い方 | カスタムフック |
| カスタムフックの命名や設計の原則 | 同上 |
| テストが書きにくいときの 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 の機能を使うものだけです。使っていないものは純粋な関数にします
- 確かめたいものが全部画面に出るなら、切り出す必要はありません
- 画面に出ない制御 (回数、間隔、呼ばれなかったこと) を確かめたくなったときが、切り出す基準です
- 利用者に見える形でテストを書いてあれば、切り出しても落ちません。落ちないことが当て先の正しさを示します
- 切り出した後は、フックと画面で確かめるものを分けます。同じことを両方に置きません
次に読む
- 12 レガシーコードへの後付け — テストが無いコードに後から入れる手順
- カスタムフック —
renderHookを使った書き方と、フックの設計