Chapter 10

Context を扱うコンポーネント:ユニットテスト

Satoshi Takeda
Satoshi Takeda
2021.02.24に更新

コンポーネント、カスタム Hooks を分割してテスト

Context(から得られる値)に関心のあるコンポーネントのユニットテストを、どういった粒度で実施するか悩ましいところです。

前章で用意した AlertProvider、Alert と AlertForm のコンポーネント、その内部で利用するカスタム Hooks といった登場人物たちを、同じ板の上で統合したユニットテストを考えることもできそうです。

しかし、今回はもう少し粒度を分けてみましょう。方針としてはユーザーが表示し利用することをコンポーネントのユニットテストだけで完結させます。コンポーネントは単体で負うべき責務範囲である表示・操作のみをテストし、任意のカスタム Hooks で取得される State Context と Dispatch Context の値・関数は別のテストとして実施していきましょう。

ここまで扱ったスパイや Hooks のためのテストなどおさらいも含みますがコンポーネントとカスタム Hooks のセクションに分けて見ていきます。

コンポーネントテスト

コンポーネントのテスト: State Context をモック実装

Alert.test.tsx
// A.ファイルから直接 export されない、包括的な名前空間を用意する
import * as AlertContext from "~/context/AlertContext"

let alertStateSpy: jest.SpyInstance<unknown>
beforeEach(() => {
  // B.上記指定した名前空間から `useAlertState` Hooks を指定してスパイ化
  alertStateSpy = jest.spyOn(AlertContext, "useAlertState")
  // C.スパイ化したうえで State 初期値が返るよう上書き、モック実装する
  alertStateSpy.mockImplementation(() => (initialState))
})
afterEach(() => {
  // D.上書きしたモック実装を解除する
  alertStateSpy.mockClear()
})

コードコメントの A, B についてですが、テストコードの下準備としてコンポーネントが利用する State Context を取得する useAlertState をスパイ化します。この時に A で実施しているとおりファイルから export される関数 useAlertState をスパイするために、名前空間を指定しています(spyOn に第一引数でその名前空間を、第二引数でメソッドである実際の関数を指定するためです)。

C ではスパイ化した useAlertState をモックで実装し挙動を変えています。テストケースの都度これらが実行されるので Alert コンポーネント内で利用した State の値は初期値として { show: false, message: "" } で実行されることになります。D ではテストケースごとにモックの実装を解除します。

さて、あとはコンポーネントがどう表示されるかをテストしていきましょう。コンポーネントのコードパスを見る限りでは UI は 2 通りの表示を得る必要がありそうです。

  1. 初期値では show: false のためコンポーネントはユーザーに表示されない
  2. 値が変更されるとコンポーネントは値を受けてユーザーに表示される

1 については DOM に何も表示されていないことをテストしています

expect(container.innerHTML).toBe("")

2 ではコンポーネント描画後に、モックで再度実装した後に rerender というテストヘルパーで再描画し結果をスナップショットしてテストしています。実装コードでは値の変更を受ければ React がコンポーネントを再描画しますが、モックされた Context の値変更では恣意的に rerender しなくてはいけません。

const { asFragment, rerender } = render(<Alert />)
alertStateSpy.mockImplementation(() => ({/** 新しい State */}))
rerender(<Alert />)
expect(asFragment()).toMatchSnapshot()

コンポーネントのテスト: Dispatch Context をモック実装

次は AlertForm のテストコードです。Dispatch Context を扱う useAlertDispatch をモックすることになりますが、前段の Alert コンポーネントでのモックとそこまで変わりません。

テストコードごとの Setup/Teardown でやっていることは前述通りモックとその解除です。今回 useAlertDispatch 自身はモックし提供する 2 つの Dispatch 関数をモックします。呼び出しや引数をテストしたいため再実装はしません。Dispatch されたあとの結果である Context State についてコンポーネントは関心がないため、ここではどう関数が呼ばれたかのみ執着しています。

AlertForm.test.tsx
// `useAlertDispatch` スパイ化
useAlertDispatchSpy = jest.spyOn(AlertContext, "useAlertDispatch")
// 以下は Dispatch 関数のモックを用意
alertShowDispatchSpy = jest.fn()
alertHideDispatchSpy = jest.fn()
useAlertDispatchSpy.mockImplementation(() => ({
  // `useAlertDispatch` を上記関数で実装する
  showDispatcher: alertShowDispatchSpy,
  hideDispatcher: alertHideDispatchSpy
}))

これで Dispatch 関数である showDispatcher, hideDispatcher が呼ばれたか・もしくはどういった引数で呼ばれたかがテスト可能になります。

あとはここまでの流れどおり、ユーザーへの表示やユーザーの操作に合わせてテストケースを考えます。コンポーネントの実装詳細を気にしだしていますが、外から Dispatch Context を注入せず内部に抱えているのでお察しください。

  1. ユーザーに初期描画時点の画面が表示されている
  2. テキストを入力して「Alert」ボタン押下後に showDispatcher がテキストを引数に呼ばれる
  3. テキストを入力し Form Submit 後に showDispatcher がテキストを引数に呼ばれる
  4. 「Alert Close」ボタンを押下後 hideDispatcher が呼ばれる
AlertForm.test.tsx
// 1 はスナップショットテストをしているのでコード略
// 2 ではユーザー操作と表示関数呼び出しを確認
fireEvent.change(input, { target: { value: "test message" } })
fireEvent.click(button)
expect(alertShowDispatchSpy).toBeCalledWith("test message")
// 3 では Form Submit なユーザー操作と表示関数呼び出しを確認
fireEvent.change(input, { target: { value: "test message" } })
fireEvent.submit(form)
expect(alertShowDispatchSpy).toBeCalledWith("test message")
// 4 ではボタン押下で非表示関数呼び出しを確認
fireEvent.click(button)
expect(alertHideDispatchSpy).toBeCalled()

カスタム Hooks テスト

Hooks のテストは前述でも見てきたとおり testing-library/react-hooks を利用します

ただこのテストライブラリの都合上、Hooks 複数を renderHook するといった挙動ができないため、今回はテストのために 2 つのカスタム Hooks をかけ合わせたテスト用の Hooks を作成しテスト時に利用しています。

AlertContext.test.tsx
function useAlertContextTest() {
  const { show, message } = useAlertState()
  const { showDispatcher, hideDispatcher } = useAlertDispatch()
  return { show, message, showDispatcher, hideDispatcher }
}

さらに前回カスタム Hooks を扱った際と違い、第二引数のオブジェクトへ wrapper というオプションを指定しテストのための Provider を指定する必要があります。これはドキュメントの Context を利用した応用にもあるとおりです。

AlertContext.test.tsx
function wrapper({ children }: { children: JSX.Element[] }) {
  return <AlertProvider>{children}</AlertProvider>
}
// テスト時に wrapper として指定する
renderHook(() => useAlertContextTest(), { wrapper })

テスト自体は Hooks の初期値がどうかと、ユーザーに表示もしくは非表示でどう呼び出されるかのみです。

  1. 初回描画では State Context には初期値がセットされている
  2. ユーザーのなにがしかの操作で Context を表示のためアップデートし State が変更される
  3. ユーザーのなにがしかの操作で Context を非表示のためアップデートし State が変更される

いずれも確認はそこまで難しくありません。

AlertContext.test.tsx
// 1 初期値を確認
expect(renderResult.current.show).not.toBe(true)
expect(renderResult.current.message).toBe("")
// アップデートした State 変更を確認
// 2
act(() => {
  renderResult.current.showDispatcher("test message")
})
expect(renderResult.current.show).toBe(true)
expect(renderResult.current.message).toBe("test message")
// 3
act(() => {
  renderResult.current.hideDispatcher()
})
expect(renderResult.current.show).not.toBe(true)

Context 自身は簡便な State、Dispatch を提供するのみだったのでそこまでテストケースも複雑にはなりませんでした。