Next.js x Jest x Testing Library のエラーで困った時にまず確認したいこと
はじめに
こちらは、Mavs Advent Calendar 2024の4日目の記事です🐺!
🌲🌲🌲
テストコード、書いてますか?
私は書いてます。そして数々のエラーと格闘してきました。
今回はそんなエラーと出会った時に、まず最初に疑いたいポイントをまとめていきます。
どれも初歩的なエラー対策かもしれませんが、知っておくのとそうでないのでは天地の差です。
皆さんの迷う時間が少しでも減るように祈りを込めてこの記事を書きます。
なお、テスト環境はタイトルにもある通り、Next.js x Jest x Testing Library を想定しています。
それでは参りましょう。
await を忘れていませんか?
まずは基本中の基本にして、うっかり中のうっかり、awaitの付け忘れです。
テスト環境内で、クリックや入力など実際のユーザーの挙動に合わせた動きをテスト環境内で再現するのは、よくあることだと思います。
そうした動作はawait で動作が完了するまで待ってあげないと、関数実行よりも先にテスト関数が実行されてしまい、意図した結果を得られなくなってしまいます。
まずは await 、これを肝に銘じたいですね。
// 簡単な例
import { render, fireEvent, waitFor } from '@testing-library/react';
test('test', async () => {
const { getByText, findByText } = render(<MyComponent />;);
// ボタンをクリック
fireEvent.click(getByText('Click me'));
// 非同期でメッセージが表示されるのを待つ
const message = await findByText('Hello, World!');
// メッセージが表示されているか確認
expect(message).toBeInTheDocument();
});
// 複数回クリックするパターン
import userEvent from '@testing-library/user-event'
const user = userEvent.setup()
test('test', async () => {
const { getByText, findByText } = render(<MyComponent />);
// 動作には必ずawait
await user.click(getByRole('button')
await user.click(getByText('送信ボタン'))
await waitFor(() => {
expect(getByText('削除確認')).toBeInTheDocument()
})
// 下記のようにwaitForにawaitをつけ忘れてもダメ
waitFor(() => {
expect(getByText('削除確認')).toBeInTheDocument()
})
})
正しいコンポーネントを取得できていますか?
こちらもあるあるです。
button要素やinput要素に関数が発火の処理が結びついていると思いきや、それらをラップしているdiv要素などに関数発火の処理が結びついていた、というパターンがあります。
特にMUIなどUIライブラリ使用の場合は、inputは表示用に使われている場合があり、div要素に関数の発火が結びついている場合があります。
解決方法としてはブラウザの検証ツールでDOMの構成をよく見ることくらいしかないかもしれません。
下記のようにroleが設定されている要素は比較的取得しやすいです。getByRoleで取得に失敗してもコンソールで取得できる候補一覧が出てきます。
ちなみにMUIの場合、セレクトボックス内の選択肢はroleに’option’を選択することで取得することができます。
// エラー内容
TestingLibraryElementError: Unable to find an accessible element with the role "test"
Here are the accessible roles:
option:
Name "sample":
<li
aria-selected="false"
data-value="5"
role="option"
tabindex="-1"
/>
roleのない要素やラップ要素は取得しづらいです。
data-testidを付与するか、querySelector駆使して要素を取得しましょう。
data-testidについては様々な意見があるかもしれませんが、個人的には要素の取得方法にこだわって時間をかけるより、まずは取得できる方法を確立し、リファクタリングの際によりよい要素の取得方法を考えるのがいいのかなと思います。
この辺りは各プロジェクトのテストの重要度で変わってきますね。
コンテキストプロバイダーをテスト環境に反映できてますか?
こちらはNext.jsに特化したお話です。
Next.jsには便利機能なhooksがたくさんありますが、テスト環境においては挙動を追うのが難しいものもあります。
状態管理の一環などで使用するコンテキストプロバイダーも、importしたコンポーネントをレンダリングする際にただのdiv要素になってしまうことがあります。
その場合は焦らず、テストファイル内にコンテキストプロバイダーをimportし、対象のコンポーネントをラップしてあげましょう。
そうすることでコンテキストが提供する挙動をテスト環境内で再現することができます。
import { ContextProvider } from '@/contexts/modal'
test('test', async ()=>{
const { getByRole } = render(
<ContextProvider>
<MyComponent />
</ContextProvider>,
)
})
カスタムフックをそのまま使っていませんか?
こちらもNext.jsに特化したお話です。
カスタムフックをテスト環境内でそのまま使うと「hookをいきなり使わないで!」とjestに怒られエラーになります。
そこで使用するのがrenderHook。
React / Next.js 開発であれば多くの人がお世話になるであろうReact Hook FormのuseFormも、renderHookを使用すればテスト環境内で実行することができます。
test('test', async ()=>{
const { result } = renderHook(() => useForm())
// currentの中にcontrolなどがあることに注意
const { getByRole } = render(
<MyComponent
name="test"
control={result.current.control}
/>,
)
})
グローバルオブジェクトやクリック関数などが使用されていませんか?
私が特定のファイルをダウンロードするコンポーネントと戦った時のTipsになります。
jest環境内で実行できないものとして、URLの生成やclick()などのJSメソッドがあります。
下記のようなファイルダウンロードの関数をjest環境で実行する際は、jest環境内で実行できないものをあらかじめモック化する必要があります。
beforeEachでテストが始まる前にモック化するのがポイントです。モック関数を外だしすることで、toHaveBeenCalledで呼び出しを確認することができます。
// 実際の実装
const downloadCall = async (data:string) => {
const a = document.createElement('a')
const byteCharacters = atob(String(data))
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const url = URL.createObjectURL(
new Blob([byteArray], { type: 'application/pdf' }),
)
a.href = url
a.download = filename
a.click()
a.remove()
window.URL.revokeObjectURL(url)
}
// テストコード
const mockCreate = jest.fn()
const mockRevoke = jest.fn()
beforeEach(() => {
URL.createObjectURL = mockCreate
URL.revokeObjectURL = mockRevoke
jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
})
afterEach(() => {
// @ts-ignore: URL.createObjectURL is mocked within beforeEach()
URL.createObjectURL.mockReset()
// @ts-ignore: URL.revokeObjectURL is mocked within beforeEach()
URL.revokeObjectURL.mockReset()
})
test('download', async () => {
// ダウンロードの実行
const downloadButton = getAllByText('ダウンロードを実行')[0]
user.click(downloadButton)
// ダウンロードが呼び出されること
await waitFor(() => {
expect(mockCreate).toHaveBeenCalled()
expect(mockRevoke).toHaveBeenCalled()
})
})
importの順番は正しいですか?
モジュールのimport順でエラーになることもありました。
以下のようにAPIモジュールをモックしていたのですが、返却地であるmockDataが初期化前に呼び出されているとjestに怒られました。
jest.mockの中でモックデータを使用する際は、テストコンポーネントよりも先にimportする必要があるようです。
// エラーパターン
import MyComponent from '.'
import { mockData } from '../../api/mock'
// 解消パターン
import { mockData } from '../../api/mock'
import MyComponent from '.'
// 職務詳細取得用のモック
jest.mock('src/api/method', () => {
return {
getData: jest.fn().mockReturnValue({
ok: true,
message: 'success',
data: mockData,
}),
}
})
test('download', async () => {
render(<MyComponent />)
})
それは本当にテストするべき項目ですか…?
こちらは意外に初心者はまりポイントだと思っていて、Nextとしての機能や特定のライブラリの機能は、そのライブラリ内でテストされているため、テストする必要はあまりないように思えます。
これはあまりにもテストの粒度を細かくした際に起きやすい問題かもしれません。
なにをどのようにテストすれば、プロジェクトの求める結果を得ることができるのか。
闇雲にコンポーネントの動きを模倣するテストよりも、エラーを発見できたり、動作を保証する理論付けのためのテストを書きたいですね。
おわりに
今回はやや専門的な内容のTips記事となりました。
皆さんのテストコード開発に少しでも役立てばと思います。
まずは、兎にも角にもawait(最初の頃本当に何回もハマった)。
以上、遠藤でした〜!