はじめに
この記事の対象者
- React(vite)を使用している
- TDDで開発を行っている
- 単体テストを充実させたい
- viewとロジックを分離させたい
これらに当てはまる人は参考になるかもしれません。
何に困っていたか
Frontendの開発を行うにあたり、viewとロジックをできる限り分離したいと思う方は非常に多いかと思います。
可読性の観点からもテストをシンプルにする観点からも、私はこの考え方が重要だと感じています。
Reactを使ったFrontendの開発を行っているので、状態管理に関わるロジックなどが記述されたコンポーネントを目にする機会は非常に多いかなと思います。
例えば以下のような単純なコンポーネントがあったとします。
import React, { useState } from 'react'
const Counter: React.FC = () => {
const [count, setCount] = useState<number>(0)
const increment = () => setCount(count + 1)
return (
<div>
<p>{count}</p>
<button onClick={increment}>Increment</button>
</div>
)
}
export default Counter
ボタンを押したらカウンターが増えるだけのコンポーネントです。
この場合、
「初期レンダリング時、viewにbuttonの要素があること」といったテストは容易に作ることができます。
しかし、「ボタンがクリックされた時、数字が押された回数だけ増加する」というようなロジックのテストを記述しようとすると、fireEvent.click
などを行う必要が出てきてViewがテストに含まれる形となります。
テストを書くとしたらこんな感じ↓
import { render, screen, fireEvent } from '@testing-library/react'
import Counter from './Counter'
test('カウントを増やす', () => {
render(<Counter />
const countElement = screen.getByText('0')
expect(countElement).toBeInTheDocument()
const buttonElement = screen.getByText('Increment')
fireEvent.click(buttonElement)
const updateCountElement = screen.getByText('1')
expect(updateCountElemnt).toBeInTheDocument()
そうすると、Viewの変更が起こった際(例えばbuttonのテキストがIncrementからAddに変わるなど)、ロジックは変更されていないのに、ロジックのテストが落ちる問題を抱えます。
Propsでステートが与えられているのであれば、DIして状態の直接的な検証を行うことができますが上記の例のようにコンポーネント自体で定義しているステートの場合、直接検証するのが難しくなってしまいます。
これをなんとかしたい!!
ので、ロジックだけを切り分ける方法をまとめたいと思います。
カスタムフックを使おう
冒頭のサンプルコードに対してuseCounter
というカスタムフックを作成してコンポーネントを記述し直すと以下のようになります。
コンポーネント
import React from 'react'
import { useCounter } from './useCounter'
const Counter: React.FC = () => {
// ここにカスタムフックを使ってviewで使うステートと関数を貰う
const { count, increment } = useCounter()
return (
<div>
<p>{count}</p>
<button onClick={increment}>Increment</button>
</div>
)
}
export default Counter
カスタムフック
import { useState } from 'react'
type UseCounterReturnType = {
count: number
increment: () => void
}
export const useCounter = (): UseCounterReturnType => {
// ここは元のファイルの内容と変わらない
const [count, setCount] = useState<number>(0)
const increment = () => setCount(count + 1)
// view側で使うステートや関数だけ返り値にする
return { count, increment }
}
で、何が嬉しいの?
って声が聞こえてきた気がするので、ロジック部分(カスタムフック)のテストを記述すると以下のようになります。
import { renderHook, act } from '@testing-library/react-hooks'
import { useCounter } from './useCounter'
test('カウントを増やす', () => {
const { result } = renderHook(() => useCounter())
// 初期値が0であることを確認
expect(result.current.count).toBe(0)
// increment関数を呼び出してカウントを1増やす
act(() => {
result.current.increment()
})
// カウントが1に増えたことを確認
expect(result.current.count).toBe(1)
})
上記テストの内容を見ると、buttonの要素やfireEventなどが介在することなく、ロジックの部分だけが純粋にテストできるようになります。
今回はシンプルな例なのでエッジケースなどはあまり考えなくていいのですが、複雑なロジックの場合エッジケースなどのテストを書こうとした時でも従来の場合と比べて記述しやすい点も多いと思います。
カスタムフックのテストの中身
カスタムフックのテストをあまり目にする機会がない人は、テストの中身がよくわからないかもしれないので、ざっくり触れていきたいと思います。
まずアレンジの部分
const { result } = renderHook(() => useCounter())
通常、Reactフックはコンポーネント内でしか利用できないですが、renderHookを使用することで、カスタムフックのロジックの部分を直接テストすることができるようになります。
`renderHook(() => useCounter())は、カスタムフックであるuseCounterを呼び出し、その返り値をテストの中で利用できるようにしています。
renderHook関数はresultというオブジェクトを返します。
resultのプロパティは、
- result.current: 現在のカスタムフックの状態
- result.error: フック内でエラーが発生した場合、そのエラー情報
が含まれています。
これにより、テストのアレンジ(前処理)ができます。
あとは、初期状態で値がどうあるべきか?であったり、
act(() => {
result.current.increment()
})
のようにして、関数を実行し、その後の値がどうあるべきかをアサーションで確認することができます。
カスタムフックを使うメリットまとめ
シンプルにロジック部分をカスタムフックとして分割しているため、
- 他のコンポーネントでも再利用可能
- TDDの効率化(Viewの変更の影響を受けない)
- テストのしやすさ
などが挙げられます。
とはいえ、全て良いことづくめではなく、デメリットも存在します。
メリットデメリットの比較
カスタムフックを分離することによるテストと背反(トレードオフ)を表形式でまとめます。
項目 | メリット・必要なテスト | 背反・デメリット |
---|---|---|
ロジックに対する単体テスト | ロジックの動作をUIに依存せず単体でテスト可能 | フックが増えると管理が複雑化 |
複数状態の管理テスト | 複数の状態が適切に管理されるか、干渉しないか確認可能 | 状態をフック間で共有する場合、複雑になることがある |
非同期処理のテスト | 非同期処理の成功/失敗の挙動を独立してテスト可能 | 非同期処理が多い場合、テストの構造が煩雑になる |
エラーハンドリングのテスト | エラー処理が適切に行われているかテスト可能 | エラーの影響範囲が広がると、テストケースが増加する |
コードの再利用性 | カスタムフックに分離することで他のコンポーネントで再利用可 | コードが細分化され、依存関係の管理が煩雑化することがある |
パフォーマンス | 状態管理が整理され、不要な再レンダリングを回避可能 | 状態を頻繁に更新するフックが増えるとパフォーマンスに影響が出る可能性がある |
UI変更の影響を抑制 | ロジックがUIに依存しないため、UI変更の影響を受けない | UIとロジックを統合的にテストする必要がある |
状態のテストが容易 | UIを通さず、状態管理やロジック部分だけのテストが可能 | 状態をグローバルに共有する必要が出た場合、管理が難しくなる |
カスタムフックを分離すると、テストのしやすさやロジックの再利用性が高まるのですが、コードが複雑化する可能性もあったり、状態管理の難易度が増す可能性もあるなど、背反が存在するのも事実です。そのため、プロジェクトの状況やチームメンバーの合意なども必要になるかと思います。
今回は、カスタムフックについて、サンプルのコードを記述しながら内容をさらっと触れてみました。