18
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SmartHRの村だよAdvent Calendar 2021

Day 3

結局のところ、E2Eとフロントエンドテスト(Jest Integration Test)のどちらでテストを書いたらいいのか

Last updated at Posted at 2021-12-02

TL;DR

Jest Integration Test でやったほうが良さそうなこと

  • First Fetch(ページの初回表示)で完結するもの
    • Mutation系(#post, #patch, #delete)はE2Eが適切そう
  • ユーザー操作が伴うが、Requestが発生しないもの
    • バリデーション
    • ボタンのDisabled属性
    • ドラッグアンドドロップ
  • パターンが複数ある場合は、1つはE2Eで行い、残りのバリエーションはJest側で担保すると良い

本題はここからになります 👉 この記事の対象とするテスト

なぜテストを書くのかを整理する

安心して開発するためにも、どんどんテスト書いていきたい

image.png

バグが減らないので、ソフトウェアごと吹き飛ばしてやる

そもそもなぜテストを書くとよいのか

  • 欠陥が減る
  • 機能要件を満たすことを確認できる

などがあり、それらのおかげでソフトウェアとしての信頼性がテストにより向上し、

  • 欠陥が検出できるので、安心して開発できる
  • テストケースによって、仕様の理解が進む

などの効果が得られます
また、

  • 欠陥が生じた時に検出できる安心感が生まることで安心して開発できる
  • コンテキストや仕様の理解がスムーズに進むなど

といったメリットがあります

この記事の対象とするテスト

次にこの記事で対象としているテストについては

  • ユニットテスト
    • 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に軍配が上がります
そのためトレードオフによる選択になるのでチームで合意が取れればどちらでもいいと思います

また、テストはないよりあるほうがいいので、迷った時は書きやすいほうで書くで十分だと思います
また参考の画像は実際の開発している画面になります

image.png
なるべくトロフィーに近い形を目指して
引用: the-testing-trophy-and-testing-classifications

Jest Integration Testを基準に分類しています
そのため、Jest Integration TestでやらないものをE2Eでやるとよいと思います


RECOMMEND(おすすめ)

Jest Integration Test で行った方がよいもの

初回のFetchで終わるもの

初回のページ表示やユーザー操作が行われないもの。
下記画像では権限や、ステータスによって表示の切り替えがある部分をテストしています。

初回表示画面
image.png

通常パターンのテーブルの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 と内容はほとんど一緒ですが、テストケースを考えるときに静的なレイアウトと混同し、忘れられがちなので明記してます

入力によって文言が変わるインフォメーションパネル(昇級者の部分が変わる)画面
image.png

タイトルのTest

expect(screen.getByRole('region', { name: '【昇給者】のダウンロードを受け付けました。' })).toBeVisible()

他にも

  • フラッシュメッセージ
  • 数値をカンマ区切りにして表示する

などをテストしています

API連携せずにフロントエンドで完結するもの

初回のFetchを除いて、ユーザーが操作があるけどRequestが発生しない部分をテストします。

編集Dialogのバリデーションとボタンの属性(バリエーションエラーの時、更新ボタンはDisabled属性になる)画面
image.png

バリデーションの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の書き方は以下の記事が詳しいです

最後に

テストを書く、布教する際に大事にしてる気持ち
image.png

18
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?