【React Testing Library】非同期コンポーネントのユニットテストにふれる

2024/10/03に公開

非同期コンポーネントのテスト

非同期処理を内包するコンポーネントのテストの書き方についてもふれておきます。
例えば次のようなコンポーネントを想定してみます。

  • 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の処理の流れについて確認しておきます。

  1. 処理が始まるとまず、文字入力中のフラグをtrueにします。その後、入力された文字をInputValueの値としてセットします。
  2. 次に、タイマーの初期化を行なっています。もし以前にセットされたタイマーの情報が残っている場合(timerRefに過去の処理の値が残っている状態であれば)は、clearTimeout関数を実行し、タイマーをリセットしています。
  3. タイマーを初期化した上で、setTimeoutの処理に進みます。setTimeoutの処理ではタイマーのリセットと入力中フラグをfalseへ変更、最後にspan要素が表示する文字列であるviewValueの値を更新しています。

このuseStateの動きは公式ドキュメントでも詳しく説明されていました。
https://ja.react.dev/learn/state-as-a-snapshot

  1. 最後に、呼び出し側からpropsとして渡された関数を実行します。
    AsyncInputコンポーネントを見ているだけでは動きがイメージしづらいので、以下のようなケースを想定してみます。
    以下はAsyncInputコンポーネントを呼び出している親コンポーネントから、入力された文字をデータベースへ保存するような処理をする関数が渡される様子です。
// 呼び出し側のコンポーネントファイル
        
// ... 
        
 const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
            postInputContent(event.target.value); # サーバー側にPOSTするような関数
          };
        
 return (
    <div>
        <AsyncInput onChange={handleInputChange} />
    </div>
    )
// ... 

テストシナリオ

AsyncInputコンポーネントの構成について把握できたところで、今回のテストシナリオを考えてみます。今回は以下4つのテストケースを考えます。

  1. 初期状態では入力欄は空であること
  2. 文字入力直後は表示部分に「Typing...」と表示されること
  3. 文字入力1秒後にに入力した内容が「入力したテキスト: <入力内容>」の形で表示されること
  4. 文字入力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要素として取得することができるようになっています。

テストケース2: 入力直後は表示部分に「Typing...」と表示されること

次は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