ご無沙汰しております。会社のプロダクトチームの新しいメンバーの方がGoのLT会に出ておられて、自分もそういった個人での活動をしたくなった次第です。
React Hooksとは
以下の公式ドキュメントが詳しいです。
https://ja.reactjs.org/docs/hooks-intro.html
前職ではAngularで開発を行なっていたのですが、コンポーネントを定義するためにクラスを定義していて、意図しないタイミングでインスタンス変数が書き換わっているみたいなことがザラにありました。
Hookがない時代のReactも触ったことがあったのですが、同じようにクラスを定義してごにょごにょしなければいけないイメージがあって、現職でReactを触り始めるまではAngularもReactもさして変わらんでしょ、と思っていました。
結論、ReactのHookによりReactがとても気に入りました。関数でコンポーネントを定義できるので理解しやすく挙動も予想しやすいですし、カスタムHookを定義することでコンポーネントのロジックの部分だけを簡単に再利用することができます。
なぜHookに対してテストを書くの?
Hookはコンポーネントのロジックを司る部分であるため、テストが書きやすく実行も早いからです。
Hookだけでなく、コンポーネントに対してももちろんテストを書くことができます。以下のレシピ集においても、DOMどんな要素が描画されているのかをテストする方法が示されています。
https://ja.reactjs.org/docs/testing-recipes.html
しかし、DOMに対するテストは書きにくいです。評価する部分を書く際にcontainer.querySelector("strong").textContent
みたいな感じで要素にアクセスする必要があるのですが、DOM構造を知っていないと書けません。要素があるかないかではなくDOM構造をテストしたいのであれば、テストを書くのではなくStorybookなんかを使って見た目を確認する方が本質的です。
また、DOMに対するテストは実行に時間がかかります。DOMの描画自体に時間がかかるというのもありますし、依存しているコンポーネントもビルドしなければならないという原因もあります。
前職ではDOMに対するテストがビルド時間短縮のボトルネックになっていて、JasmineからJestに移行することでいくらか速くならないかみたいなことを試行錯誤していました。
そこで、Hookに対してテストを書くわけです。HookにはDOM構造の概念がなく、stateに対して評価が書けます。DOMを描画する処理もなく、他のHookに依存しているみたいなことも少ないです。
どうやって書くの?
以下のドキュメントにある書き方を拝借させていただきます。
https://react-hooks-testing-library.com/usage/basic-hooks
こんな感じのカスタムHookがあれば、
import { useState, useCallback } from 'react'
export default function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = useCallback(() => setCount((x) => x + 1), [])
return { count, increment }
}
こんな感じでテストがかけます。
import { renderHook, act } from '@testing-library/react-hooks'
import useCounter from './useCounter'
test('should increment counter from custom initial value', () => {
const { result } = renderHook(() => useCounter(9000))
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(9001)
})
よくないですか?? 評価対象がHookの返り値としてreturnされるので、result.current.count
みたいにさくっとアクセスできちゃいます。Hook内で作ったCallbackを呼び出したい時も、結局Hookの返り値としてreturnされるので、result.current.increment()
みたいにさくっと呼び出せちゃいます。
useEffectとか絡んだらむずそうじゃない?
最近難しそうな場合もテストかけちゃったので紹介させてください。
import * as React from "react";
export const useResult = () => {
const [formValue, setFormValue] = React.useState<FormValue>("");
const [result, setResult] = React.useState<string>("");
const getResult = React.useCallback(async (formValue) => {
try {
const { data } = await apiClient.getResult(formValue);
setResult(data);
} catch (e) {
setResult(e);
}
}, [apiClient.getResult, setResult]);
React.useEffect(() => {
setResult("");
getResult(formValue);
}, [formValue]);
return {
formValue,
setFormValue
result
};
};
みたいなコードです。これはuseEffectを使って formValue
を監視して、useEffect内でstateを更新しているため、actで囲むことができません。以下のような感じで書くとエラーになっちゃいます。
import { renderHook, act } from '@testing-library/react-hooks'
import useCounter from './useCounter'
test('result updated', () =>; {
const { result: renderResult } = renderHook(() =>
useResult()
);
act(() => {
renderResult.current.setFormValue("VALUE"); // Warning: An update to TestComponent inside a test was not wrapped in act(...).
});
expect(getResultMock.mock.calls.length).toEqual(1);
expect(renderResult.current.result).toEqual(want);
})
そこで、以下のstackoverflowを読んで、
以下のオプションを使って、
https://react-hooks-testing-library.com/reference/api#waitfornextupdate
以下のようにすることで解決しました。
import { renderHook, act } from '@testing-library/react-hooks'
import useCounter from './useCounter'
test('result updated', () =>; {
const { result: renderResult, waitForNextUpdate } = renderHook(() =>
useResult()
);
act(() => {
renderResult.current.setFormValue("VALUE");
});
await waitForNextUpdate();
expect(getResultMock.mock.calls.length).toEqual(1);
expect(renderResult.current.result).toEqual(want);
})
これでuseEffect内で発火される更新もちゃんと待ってくれます。
まとめ
バックエンドに比べるとフロントエンドのテストはサボりがちになるのですが、見た目の部分はStorybookに任せてロジックの部分をHookのテストでカバーしていきたいです💪