TL;DR
Jest Integration Test でやったほうが良さそうなこと
- First Fetch(ページの初回表示)で完結するもの
- Mutation系(#post, #patch, #delete)はE2Eが適切そう
- ユーザー操作が伴うが、Requestが発生しないもの
- バリデーション
- ボタンのDisabled属性
- ドラッグアンドドロップ
- パターンが複数ある場合は、1つはE2Eで行い、残りのバリエーションはJest側で担保すると良い
本題はここからになります 👉 この記事の対象とするテスト
なぜテストを書くのかを整理する
安心して開発するためにも、どんどんテスト書いていきたい
バグが減らないので、ソフトウェアごと吹き飛ばしてやる
そもそもなぜテストを書くとよいのか
- 欠陥が減る
- 機能要件を満たすことを確認できる
などがあり、それらのおかげでソフトウェアとしての信頼性がテストにより向上し、
- 欠陥が検出できるので、安心して開発できる
- テストケースによって、仕様の理解が進む
などの効果が得られます
また、
- 欠陥が生じた時に検出できる安心感が生まることで安心して開発できる
- コンテキストや仕様の理解がスムーズに進むなど
といったメリットがあります
この記事の対象とするテスト
次にこの記事で対象としているテストについては
- ユニットテスト
- custom Hookなどの関数のテスト
- 結合テスト
- コンポーネント単位のテスト
- ページ単位のテスト
- E2Eテスト
- ソフトウェア全体のテスト
ユニットテストの重要性はいろいろな記事で言及されているので、
- 結合テストとE2Eどちらのテストを書くのがいいのか
についてに焦点を当てています
この記事の環境
{
"react": "^17.0.2",
"jest": "^27.3.1",
"@testing-library/jest-dom": "^5.15.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"msw": "^0.35.0",
}
用語の定義
- Jest Integration Test
- Jest で行うフロントエンドの、ページ単位の結合テストです
- ページ単位のテスト∽コンポーネント単位の結合テストになっています
- E2E
- End to Endで行うソフトウェア全体のテストです
Jest Integration Test と E2E のどちらで書くのか
心構え
一般的には、信頼性はE2Eの方が高く、コストはIntegration Testに軍配が上がります
そのためトレードオフによる選択になるのでチームで合意が取れればどちらでもいいと思います
また、テストはないよりあるほうがいいので、迷った時は書きやすいほうで書くで十分だと思います
また参考の画像は実際の開発している画面になります
なるべくトロフィーに近い形を目指して
引用: the-testing-trophy-and-testing-classifications
Jest Integration Testを基準に分類しています
そのため、Jest Integration TestでやらないものをE2Eでやる
とよいと思います
RECOMMEND(おすすめ)
Jest Integration Test で行った方がよいもの
初回のFetchで終わるもの
初回のページ表示やユーザー操作が行われないもの。
下記画像では権限や、ステータスによって表示の切り替えがある部分をテストしています。
通常パターンのテーブルのTest
expect(within(preRegistrations[0]).getByRole('cell', { name: '適用待ち' })).toBeVisible()
expect(within(preRegistrations[0]).getByRole('cell', { name: '2030/12/01' })).toBeVisible()
expect(within(preRegistrations[0]).getByRole('cell', { name: '昇給者' })).toBeVisible()
expect(within(preRegistrations[0]).getByRole('cell', { name: '従業員(568名)' })).toBeVisible()
expect(within(preRegistrations[0]).getByRole('cell', { name: '2030/12/08' })).toBeVisible()
バリエーションとして、Statusによって切り替わるケースTest
userEvent.click(within(preRegistrations[3]).getByRole('button', { name: '予約を操作' }))
userEvent.hover(screen.getByTestId('pre-registration-dropdown-menu-link-delete-info-icon'))
expect(within(preRegistrations[3]).getByRole('cell', { name: '適用済み' })).toBeVisible()
expect(screen.getByRole('tooltip', { name: '削除可能期限を過ぎているため削除できません。' })).toBeVisible()
他にも
- ページネーションの表示/非表示の切り替え
- Query Parameterが含まれる直叩きのURLによって表示が切り替わる部分
などをテストしています
動的に表示が変わるレイアウト
コンポーネント側で担保したりすることが多いです
初回のfetch
と内容はほとんど一緒ですが、テストケースを考えるときに静的なレイアウトと混同し、忘れられがちなので明記してます
入力によって文言が変わるインフォメーションパネル(昇級者の部分が変わる)画面
タイトルのTest
expect(screen.getByRole('region', { name: '【昇給者】のダウンロードを受け付けました。' })).toBeVisible()
他にも
- フラッシュメッセージ
- 数値をカンマ区切りにして表示する
などをテストしています
API連携せずにフロントエンドで完結するもの
初回のFetchを除いて、ユーザーが操作があるけどRequestが発生しない部分をテストします。
編集Dialogのバリデーションとボタンの属性(バリエーションエラーの時、更新ボタンはDisabled属性になる)画面
バリデーションのTest
input.setSelectionRange(0, 3)
userEvent.type(input, '{backspace}')
expect(screen.getByText('予約名を入力してください。')).toBeVisible()
expect(screen.getByRole('button', { name: '更新' })).toBeDisabled()
userEvent.type(input, 'あ'.repeat(31))
expect(screen.getByText('予約名は30文字以内で入力してください。')).toBeVisible()
expect(screen.getByRole('button', { name: '更新' })).toBeDisabled()
他にも
- ダイアログの表示、非表示
- ドラックアンドドロップ
などをテストしています
OPTIONAL(任意)
Jest Integration Test でやったほうがいいけど、そこまで重要度が高くないものになります
どこまでやるかは、開発者やQAで合意が取れてればよいと思います
静的なレイアウト
テーブルのヘッダーや、ダイアログのタイトルなどがこれに当たります
どこまで文言をテストするのか、それらをテストする効果はどれくらいあるのか、などは曖昧ですが翻訳などが関わるのであればテストで変更箇所を検知できるといいと思います
また、テストをするのであれば、コンポーネント単位で行うのが適切だと思います
テーブルのヘッダーのTest
const th = rows[0]
expect(within(th).getByText('ステータス')).toBeVisible()
expect(within(th).getByText('適用日')).toBeVisible()
expect(within(th).getByText('予約名')).toBeVisible()
expect(within(th).getByText('対象')).toBeVisible()
expect(within(th).getByText('作成者')).toBeVisible()
アクセシビリティ(Role)
ユーザーのソフトウェアの利用に近い形でテストすることで、アクセシビリティを最低限テストできます
具体的にはReact Testing Libraryを持ちいた、Elementの取得する際に*ByRole
メソッドを使うことで、Roleの確認が行えます
要素の取得方法の優先順位についてはドキュメントにあります
https://testing-library.com/docs/queries/about#priority
ローディングなどのUIロジック
ローディングやユーザーイベントに起因するUI変更などのUIロジックのテストです
ただ、テストしづらいので、マニュアルテストで担保するでも十分だと思います
- ページ内ローディング→絞り込み実行→テーブル内ローディング
- ユーザーがイベントを実行すると、表示位置が変わる(window.scrollTo(0,0))
- ユーザーがイベントを実行すると、URL.searchParamsが変わる
などをテストしています
400系
E2Eでは発生させずらいネットワークエラー(HTTP Status: 400)のテストをすることで、フロントエンドのバグや、UIのテストをすることが可能になります
今の環境では、MSWというネットワーク層インターセプトをするMocking Libraryを使っているので、それを用いた400系のテストをしています
予約のダウンロードに失敗するTest
// APIをMockでテストしてる
server.use(getPreRegistrationsExportExpect400)
await screen.findByText('予約管理')
const preRegistration = getPreRegistrations()[0]
userEvent.click(within(preRegistration).getByRole('button', { name: 'ダウンロード' }))
expect(within(await screen.findByRole('alert')).getByText('予約のダウンロードに失敗しました。')).toBeVisible()
Mockの中身
export const getPreRegistrationsExportExpect400 = rest.get(`${path.api.pre_registrations.export.mock}`, (_, res, ctx) => {
return res(
ctx.status(400),
ctx.json({
message: 'ダウンロードに失敗しました。',
}),
)
})
APIを叩いた時のRequest BodyやQuery Parameter
ここでInputによって変わるRequest Bodyの中身やQuery Paramterが正しく渡されているかを担保するテストを書きます
これらは実装の詳細テストになり、E2E側でも担保されるものが適切なので任意になっています
ただ、リファクタリング等をするのであればテストを書いた方が信頼感、安心感は高くなると思います
名前の編集のTest
const input: any = screen.getByLabelText('予約名')
input.setSelectionRange(0, 3)
userEvent.type(input, '{backspace}昇格者降格者')
userEvent.click(screen.getByRole('button', { name: '更新' }))
await expectSecondFetch()
expect(within(await screen.findByRole('alert')).getByText('予約名を更新しました。')).toBeVisible()
const targetsRequest = listenerRequest.mock.calls
.flat()
.filter((it) => matchEndpoint(it.url.pathname) && it.method === 'PATCH')
expect(targetsRequest[0].body).toEqual({
name: '昇格者降格者',
})
listenerRequestはmock関数になっており、MSW側でcatchされた内容を格納しています
const listenerRequest = jest.fn<MockedRequest, MockedRequest[]>()
server.on('request:match', (req) => {
listenerRequest(req)
})
FAQ
E2E と Jest Integration Test どっちでもテストを書けるんだけど最適なのはどっち??
E2Eではソフトウェア全体で正しく動いてるのか確認するためにテストを1つ書いて、Jest Integration Test でバリエーションを網羅するように書くとポートフォリオのバランスがよくなります
E2Eに比べてJest Integration Testが書きづらい
テストはないよりはある方が確実によいのでチーム内で合意が取れていれば、E2Eでも問題ないと思っています
Jest Integration Testを追加することに越したことはないんですが、各々のテスト環境にそれぞれ癖があると思うので、無理する必要はないと思います
テストのいい感じの書き方を教えて欲しい
describeは対象や条件
、itは期待する結果
を書きます
it
は仕様書に結びつくとメンテがしやすくなるので機能要件をもとに書くとよいです
また、it == Assertion(Expect)
になるので、Assertionが多すぎるのであればテストを分割してもよいです
そのため、1つのテストに複数のAssertionが含まれるのは問題ないです
コンテンツが表示され、ページネーションが非表示になる
it('コンテンツが表示され、ページネーションが非表示になる', () => {
// assert: コンテンツが表示される
const preRegistrations = rows.slice(1)
expect(within(preRegistrations[0]).getByRole('cell', { name: '適用待ち' })).toBeVisible()
...
// assert: ページネーション が非表示
expect(screen.queryByLabelText('ページネーション')).not.toBeInTheDocument()
})
またフロントエンドのテストは肥大化しやすいので、AAAパターン
を使うとわかりやすくなります
AAAパターンとは、Arrange(テストのための準備)、Act(処理の実行)・Assert(結果の確認)の三つのブロックに分けて書く方法です
コンテンツの削除に成功し、インフォメーションパネルとフラッシュメッセージが表示されるTest
it('コンテンツの削除に成功し、インフォメーションパネルとフラッシュメッセージが表示される', () => {
// Arrange(テストのための準備)
const table = screen.getByRole('table')
const deleteButtonInTable = table.getByRole('button', { name: '削除' })
// Act(処理の実行)
userEvent.click(deleteButtonInTable)
// Assert(結果の確認)
// expectはインフォメーションパネルが表示されること
expect(within(await screen.findByRole('alert')).getByText('昇給者の削除を受け付けました。')).toBeVisible()
expect(screen.getByRole('region', { name: '【昇給者】の削除を受け付けました。' })).toBeVisible()
// Arrange
const link = screen.getByRole('link', { name: 'バックグラウンド処理' })
// Assert
// expectはフラッシュメッセージが表示されること
expect(link).toBeVisible()
expect(link).toHaveAttribute('href', '/admin/job_histories')
})
AAA Paternの書き方は以下の記事が詳しいです