【React Testing Library】非同期コンポーネントのユニットテストにふれる
非同期コンポーネントのテスト
非同期処理を内包するコンポーネントのテストの書き方についてもふれておきます。
例えば次のようなコンポーネントを想定してみます。
- input要素からなる文字入力コンポーネント
- inputエリアのすぐ外側に、入力された文字を表示するspan要素を持っている。
- inputエリアに文字を入力した直後の1秒間は
Typing...
という文字が表示される。 - 文字入力後1秒経過すると表示部分に入力内容が表示される。
以下コンポーネントのコード例です。
import React, { useCallback, useRef, useState } from 'react'
type AsyncButtonProps = {
onChange: React.ChangeEventHandler<HTMLInputElement>;
}
export const AsyncInput = (props: AsyncButtonProps) => {
const { onChange } = props
const [isTyping, setIsTyping] = useState(false)
const [inputValue, setInputValue] = useState('')
const [viewValue, setViewValue] = useState('')
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setIsTyping(true)
setInputValue(e.target.value)
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
}
timerRef.current = setTimeout(() => {
timerRef.current = null
setIsTyping(false)
setViewValue(e.target.value)
onChange(e)
}, 1000)
}, [onChange])
const text = isTyping ? 'Typing...' : `入力したテキスト: ${viewValue}`
return (
<div>
<input data-testid="input-text" value={inputValue} onChange={handleChange} />
<span data-testid="display-text">{text}</span>
</div>
)
}
次に進む前に、このコードの内容理解に苦しんだので、それぞれ確認しておきます。
-
onChange
イベントの型
以下の部分で、このコンポーネントにpropsとして渡されるonChange関数の型を定義しています。type AsyncButtonProps = { onChange: React.ChangeEventHandler<HTMLInputElement>; }
このうち、
React.ChangeEventHandler
はイベントハンドラ関数そのものの型を表しています。それに続ける形でイベントを発生させるDOM要素の型を<>
に指定しています。今回はpropsとしてHTMLInputElement
に対するイベントハンドラ関数が渡されるということを型定義しています。 -
useRef
フックとReturnType型
以下の部分ではuseRef
フックを使用しています。const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useRef
とはReactのフックの一つで、useState
と同様にデータを保持する機能を持っています。しかし、useState
とは異なりuseRef
に保持されている値が変更されても、コンポーネントは再レンダリングされません。また、他の契機によってコンポーネントが再レンダリングされた場合でもuseRef
に保持された値はリセットされずに維持されます。今回のケースではタイマーの値の変化に伴う再レンダリングを防止する目的に加え、useStateを利用している値が変更するたび(例えば、文字入力のたび)に再レンダリングされたとしても、「文字入力完了後1秒経過すること」を管理するためのタイマーの値はリセットされないようにするためにuseRef
を利用しています。useRef
は以下の形で初期化します。const refオブジェクト = useRef<保持したい値の型>(初期値)
そして、
useRef
から初期化されたオブジェクトから保持データを取り出すにはオブジェクト名.current
の形で参照します。
今回の場合、timerRefオブジェクトが保持する値の型として、ReturnType<typeof setTimeout>
もしくはnull
を設定しています。前半部分のReturnType
というのはTypeScriptのユーティリティ型の一つで、関数の戻り値の型を取得するために利用され、以下のような形で使用します。ReturnType<typeof 関数>
つまり、timerRefオブジェクトは、
setTimeoutという関数が返す戻り値の型
もしくはnull型
をデータとして保持するということになります。また、null型が使用されるのは、タイマーがまだ設定されていない状態を考慮するためです。 -
handleChange
関数の中身についても見ていきます。-
useCallback
handleChange
関数はuseCallback
でラップされています。これは、
AsyncInput
コンポーネントがuseStateを利用しているコンポーネントであり、文字入力のたびに再レンダリングされてしまう構成となっているため、その再レンダリングのたびにhandleChange
関数が都度生成されるのを防ぐためです。
ここではuseCallback
の依存配列(dependencies
)に登録したonChange
の値が変更されない限り、同じ関数のインスタンスを再利用するようにしています。
onChange
とは冒頭で型定義したように、このAsyncInput
コンポーネントに対してprops
として外部から渡される関数です。
-
handleChange
の処理の流れについて確認しておきます。
- 処理が始まるとまず、文字入力中のフラグを
true
にします。その後、入力された文字をInputValue
の値としてセットします。 - 次に、タイマーの初期化を行なっています。もし以前にセットされたタイマーの情報が残っている場合(
timerRef
に過去の処理の値が残っている状態であれば)は、clearTimeout
関数を実行し、タイマーをリセットしています。 - タイマーを初期化した上で、
setTimeout
の処理に進みます。setTimeout
の処理ではタイマーのリセットと入力中フラグをfalse
へ変更、最後にspan
要素が表示する文字列であるviewValue
の値を更新しています。
このuseStateの動きは公式ドキュメントでも詳しく説明されていました。
- 最後に、呼び出し側からpropsとして渡された関数を実行します。
AsyncInputコンポーネントを見ているだけでは動きがイメージしづらいので、以下のようなケースを想定してみます。
以下はAsyncInputコンポーネントを呼び出している親コンポーネントから、入力された文字をデータベースへ保存するような処理をする関数が渡される様子です。
// 呼び出し側のコンポーネントファイル
// ...
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
postInputContent(event.target.value); # サーバー側にPOSTするような関数
};
return (
<div>
<AsyncInput onChange={handleInputChange} />
</div>
)
// ...
テストシナリオ
AsyncInputコンポーネントの構成について把握できたところで、今回のテストシナリオを考えてみます。今回は以下4つのテストケースを考えます。
- 初期状態では入力欄は空であること
- 文字入力直後は表示部分に「
Typing...
」と表示されること - 文字入力1秒後にに入力した内容が「
入力したテキスト: <入力内容>
」の形で表示されること - 文字入力1秒後にonChangeコールバックが呼ばれること(おまけつき)
テストケース1:初期状態では入力欄は空であること
テストファイルを書いていきます。AsyncInput
コンポーネントと同じディレクトリにindex.spec.tsx
を作成します。
構成としては前回記事のコンポーネントテストのコードと同様で、describe
関数内にit
を使ってテストケースを書いていきます。
ただ今回は、テストの中でAsyncInput
コンポーネントを呼び出すときに、propsとして関数を渡す必要があります。
テストを成立させるために適当な関数を定義したいのですが、jestを使ったテストではjest.fn()
関数というものが使用でき、これを利用することで「モック関数」を作成することができます。このモック関数を使ってテストコードの中で、AsyncInput
コンポーネントに対して適切なpropsを渡すという動きを再現することができます。
テストコード例は以下の通りです。
import { render, screen, RenderResult } from '@testing-library/react'
import { AsyncInput } from './index'
describe('AsyncInput', () => {
let renderResult: RenderResult
let handleChange: jest.Mock
beforeEach(() => {
handleChange = jest.fn()
renderResult = render(<AsyncInput onChange={handleChange} />)
})
afterEach(() => {
renderResult.unmount()
})
it('should display empty in display area on initial render', () => {
const spanNode = screen.getByTestId('display-text') as HTMLSpanElement
expect(spanNode).toHaveTextContent('入力したテキスト:')
})
})
テストコードの中でDOM要素を取得する際、今回はgetByTestId
という関数を使用しています。
これは元となるコンポーネントのHTML要素にあらかじめdata-testid
というオプションを設定しており、その値を指定することで、テストコード側で任意のDOM要素として取得することができるようになっています。
Typing...
」と表示されること
テストケース2: 入力直後は表示部分に「次はfireEvent
関数を使ってinput要素に文字を入力する動きをシミュレートしています。
import { render, screen, RenderResult, fireEvent } from '@testing-library/react'
import { AsyncInput } from './index'
describe('AsyncInput', () => {
// ...
it('should display "Typing..." while typing', () => {
const inputText = 'テスト入力'
const inputNode = screen.getByTestId('input-text') as HTMLInputElement
fireEvent.change(inputNode, { target: { value: inputText } })
const spanNode = screen.getByTestId('display-text') as HTMLSpanElement
expect(spanNode).toHaveTextContent('Typing...')
})
})
入力したテキスト: <入力内容>
」の形で表示されること
テストケース3: 文字入力1秒後にに入力した内容が「上記2つのテストケースは前回記事で扱った内容と同じです。ここから非同期処理を内包する処理のテストを書いていきます。
では、テストの中で「1秒経過した」ことを再現するにはどうすれば良いかを考えてみます。
実際にテストケース内でsetTimeout
関数を呼び出すということでも対応できそうですが、一定時間待機が必要なテストが今後増えていくと、テスト実行にかかる時間が増大してしまい、あまり良い方法とは言えなさそうです。
この場合にもjest
の提供するモックが利用できます。jest
ではタイマーモックが提供されており、実際に待機することなくタイマーのコールバックを実行することができます。
タイマーモック使用の流れは以下のようになります。
-
beforeEach
関数の内部でjest.useFakeTimers()
を呼び出し、後続のテストケースにおいてモックタイマーを有効化します。 -
afterEach
関数の内部ではjest.clearAllTimers()
を呼び出し、テストケース終了後にモックタイマーをリリースします。 - テストコード内部で
jest.runAllTimers()
を実行し、タイマーのコールバック内部で発生した状態変更をテストのコンテキストに反映します。
タイマーモックの使用により一定時間待機が必要な処理の結果をテストコンテキストに反映することができたら、最後に文字表示エリアに入力された内容が表示されていることを確認するマッチャを書けばテストコードは完成です。
import { render, screen, RenderResult, fireEvent } from '@testing-library/react'
import { AsyncInput } from './index'
import { act } from 'react'
describe('AsyncInput', () => {
let renderResult: RenderResult
let handleChange: jest.Mock
beforeEach(() => {
jest.useFakeTimers()
handleChange = jest.fn()
renderResult = render(<AsyncInput onChange={handleChange} />)
})
afterEach(() => {
renderResult.unmount()
jest.clearAllTimers()
})
// ...
it('should display input text in display area after 1 second', async () => {
const inputText = 'テスト入力'
const inputNode = screen.getByTestId('input-text') as HTMLInputElement
fireEvent.change(inputNode, { target: { value: inputText } })
await act(() => {
jest.runAllTimers()
})
const spanNode = screen.getByTestId('display-text') as HTMLSpanElement
expect(spanNode).toHaveTextContent(`入力したテキスト: ${inputText}`)
})
})
なお、非同期処理を検証する上記のテストではasync/await
を利用しています。関数の頭にasync
キーワードを付け、その内部で実行される非同期処理にawait
を使用することで、後続の処理がその非同期処理の結果を待ってから実行されるように順序づけています。
jest.runAllTimers()
をラップしているact
関数は、React Testing Libraryから提供されているユーティリティで、コンポーネントの状態変更や副作用を扱う際に使用されます。非同期操作はact
関数内で実行することで、Reactの状態管理の整合性を保ちながら、正しい状態をコンテキストに反映させることができます。
テストケース4: 文字入力1秒後にonChangeコールバックが呼ばれること
今回も先ほどと同様にjest.runAllTimers()
を使って、テストのコンテキストにタイマーのコールバック内で呼ばれる処理を反映させます。
その上で、onChange
関数が呼び出されたかどうかを確認します。
特定の関数が実行されたかどうかを確認するには、toHaveBeenCalled
マッチャを使用します。
import { render, screen, RenderResult, fireEvent } from '@testing-library/react'
import { AsyncInput } from './index'
import { act } from 'react'
describe('AsyncInput', () => {
// ...
it('should call onChange function after 1 second', async () => {
const inputText = 'テスト入力'
const inputNode = screen.getByTestId('input-text') as HTMLInputElement
fireEvent.change(inputNode, { target: { value: inputText } })
await act(() => {
jest.runAllTimers()
})
expect(handleChange).toHaveBeenCalled()
})
})
またtoHaveBennCalledWith
を使えばhandleChange
が呼ばれることだけでなく、呼ばれた際の引数についてもテストすることができます。
このマッチャではhandleChage
関数が呼ばれたかどうかということだけでなく、入力された値が引数として渡されているかということも検証しています。
import { render, screen, RenderResult, fireEvent } from '@testing-library/react'
import { AsyncInput } from './index'
import { act } from 'react'
describe('AsyncInput', () => {
//...
it('should call onChange function with the correct value after 1 second', async () => {
const inputText = 'テスト入力'
const inputNode = screen.getByTestId('input-text') as HTMLInputElement
fireEvent.change(inputNode, { target: { value: inputText } })
await act(() => {
jest.runAllTimers()
})
expect(handleChange).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.objectContaining({
value: inputText
})
})
})
expect(handleChange).toHaveBeenCalledWith(inputText)
でもいけそうな気がしたのですが、前回の記事でもふれたように、handleChange
の引数、つまりonChange
イベントハンドラの引数はeventオブジェクトを受け取るため、単純な文字列をそのまま渡すことはできません。そのため、{ target: { value: inputText } }
というオブジェクトを指定しています。
さらに、もう"ふた捻り"あります…。
eventオブジェクトはtargetプロパティ以外にも多くのプロパティを持っています。そのため、直接{ target: { value: inputText } }
オブジェクトをtoHaveBeenCalledWith
マッチャの引数として渡すと、オブジェクトの一致性を適切に検証することができません。
そのため、toHaveBeenCalledWith
マッチャの中でexpect.objectContaining()
を実行し、eventオブジェクトの中の特定のプロパティが部分的に一致しているかどうかをテストしています。
さらに、eventオブジェクトのtargetプロパティも同様に、value以外のプロパティを持ちうるため、targetに対してもexpect.objectContaining()
を使用します。
以上の内容で今回用意したすべてのテストがパスするはずです。
今回も長くなりましたが、お読みいただきありがとうございました。
Discussion