Chapter 11

単体テスト

Thirosue
Thirosue
2021.09.08に更新

サンプルでは、JestおよびReact Testing Libraryを用いてコンポーネントの単体テストを実施しています。

本チャプターでは、作成したサンプルについて、紹介していきます。

https://testing-library.com/docs/react-testing-library/intro/

テストの実行

yarn testでテストを実行します。

$ yarn test
yarn run v1.22.10
$ jest

...(中略)...

    console.error
      { response: { status: 500 } }

      at node_modules/react-query/lib/core/mutation.js:114:32


Test Suites: 3 passed, 3 total
Tests:       25 passed, 25 total
Snapshots:   3 passed, 3 total
Time:        3.175 s, estimated 4 s
Ran all test suites.
✨  Done in 4.70s.

ページオブジェクトパターン

以下で紹介されているページオブジェクトパターンを意識してテストを記載していきます。

ページオブジェクトは、テストメンテナンスを強化し、コードの重複を減らすためのテスト自動化で一般的になったデザインパターンです。

https://www.selenium.dev/ja/documentation/guidelines/page_object_models/

部品コンポーネントのテスト(ページネーション)

サンプルではページネーションコンポーネントを自作したため、単体テストクラスを作成し、テストを実施しました。

test/compornents/molecules/pager.test.tsx
import React from 'react'
import { render } from '../../testUtils'
import { PageItem } from '../../../data/page-item'
import Pager from '../../../components/molecules/pager'

// テストで利用するクラスの型定義
type Pages = {
  page1: Element
  page2: Element
  page3: Element
  page4: Element
  page5: Element
  page6: Element
  page7: Element
  page8: Element
  page9: Element
  page10: Element
}

// テストで利用するページオブジェクトクラスの定義
class Page {
  container: HTMLElement
  constructor(container: HTMLElement) {
    this.container = container
  }

  一ページ目リンク(): Element {
    return this.container.querySelector('.page-link-1')
  }

  二ページ目リンク(): Element {
    return this.container.querySelector('.page-link-2')
  }

  三ページ目リンク(): Element {
    return this.container.querySelector('.page-link-3')
  }

  四ページ目リンク(): Element {
    return this.container.querySelector('.page-link-4')
  }

  五ページ目リンク(): Element {
    return this.container.querySelector('.page-link-5')
  }

  六ページ目リンク(): Element {
    return this.container.querySelector('.page-link-6')
  }

  七ページ目リンク(): Element {
    return this.container.querySelector('.page-link-7')
  }

  八ページ目リンク(): Element {
    return this.container.querySelector('.page-link-8')
  }

  九ページ目リンク(): Element {
    return this.container.querySelector('.page-link-9')
  }

  十ページ目リンク(): Element {
    return this.container.querySelector('.page-link-10')
  }

  ページリンク一覧(): Pages {
    return {
      page1: this.一ページ目リンク(),
      page2: this.二ページ目リンク(),
      page3: this.三ページ目リンク(),
      page4: this.四ページ目リンク(),
      page5: this.五ページ目リンク(),
      page6: this.六ページ目リンク(),
      page7: this.七ページ目リンク(),
      page8: this.八ページ目リンク(),
      page9: this.九ページ目リンク(),
      page10: this.十ページ目リンク(),
    }
  }
}

const handleClick = async (): Promise<void> => {}

describe('Pager components', () => {
 // HTMLスナップショットのテスト
  it('matches snapshot', () => {
    const item: PageItem = {
      page: 1,
      totalPage: 10,
      totalCount: 97,
      perPage: 10,
    }
    const { asFragment } = render(
      <Pager pageItem={item} search={handleClick} />,
      {}
    )
    expect(asFragment()).toMatchSnapshot()
  })

  it('全10ページ かつ 現在1ページ目を指定しているとき、ページャの数が期待値どおりであること', () => {
    const item: PageItem = {
      page: 1,
      totalPage: 10,
      totalCount: 97,
      perPage: 10,
    }
    const { container } = render(
      <Pager pageItem={item} search={handleClick} />,
      {}
    )
    const page = new Page(container) //ページオブジェクトを生成

    // 期待値
    // [1] 2 3 4 ... 10
    const { page1, page2, page3, page4, page5, page9, page10 } =
      page.ページリンク一覧()

   // 一ページ目が表示されカーソルが当たっていること
    expect(page1).toBeTruthy()
    expect(page1.className).toContain('cursor-not-allowed')

   // 二ページ目が表示されカーソルが当たっていないこと
    expect(page2).toBeTruthy()
    expect(page2.className).not.toContain('cursor-not-allowed')

    expect(page3).toBeTruthy()
    expect(page4).toBeTruthy()

   // 五ページ目が表示されてないこと
    expect(page5).toBeNull()
    expect(page9).toBeNull()

    expect(page10).toBeTruthy()
    expect(page10.className).not.toContain('cursor-not-allowed')
  })
  
  ...(中略)...
})

ページコンポーネントのテスト

テストユーティリティの準備

ページコンポーネントを表示する際、本サンプルの様にアプリケーションに共通なプロバイダーでラップすることがよくあります。サンプルプロジェクトでは、React Queryやダイアログ表示用のカスタムプロバイダーなどでページコンポーネントをラップしていました。

公式にあるように、これをグローバルに利用できるようにするには、React Testing Libraryからすべてを再エクスポートするユーティリティファイルを定義する方法が便利です。

https://testing-library.com/docs/react-testing-library/setup/
test/testUtils.tsx
import React, { FC, ReactElement } from 'react'
import { render, RenderOptions } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from 'react-query'
import GlobalStateProvider from '../context/global-state-provider'
import ConfirmProvider from '../context/confirm-provider'

const queryClient = new QueryClient()

const AllTheProviders: FC = ({ children }) => {
  return (
   // ページコンポーネントのテストで必要なプロバイダーでラップする
    <QueryClientProvider client={queryClient}>
      <ConfirmProvider>
        <GlobalStateProvider>{children}</GlobalStateProvider>
      </ConfirmProvider>
    </QueryClientProvider>
  )
}

const customRender = (
  ui: ReactElement,
  options?: Omit<RenderOptions, 'wrapper'>
) => render(ui, { wrapper: AllTheProviders, ...options })

export * from '@testing-library/react'
export { customRender as render }

ログインページのテスト

上で紹介したユーティリティを利用して、各ページのテストを実行していきます。
以下はログインページのテストの例です。

test/pages/login.test.tsx
import React from 'react'
import { act } from 'react-dom/test-utils'
// テストユーティリティ経由でrenderオブジェクトを利用する
import { render, fireEvent } from '../testUtils'
import Login from '../../pages/login'
import { setCookie, parseCookies, destroyCookie } from 'nookies'
import axios from 'axios'

// 画面遷移のrouterをモック化する
jest.mock('next/router', () => ({
  useRouter() {
    return {
      push: jest.fn(),
    }
  },
}))

// テストで利用するページオブジェクトクラスの定義
class Page {
  container: HTMLElement
  constructor(container: HTMLElement) {
    this.container = container
  }

  Eメール入力エリア(): Element {
    return this.container.querySelector('#email')
  }

  Eメールエラーメッセージエリア(): Element {
    return this.container.querySelector('.email-error-message-area')
  }

  パスワード入力エリア(): Element {
    return this.container.querySelector('#password')
  }

  パスワードエラーメッセージエリア(): Element {
    return this.container.querySelector('.password-error-message-area')
  }

  オートログインチェックボックス(): Element {
    return this.container.querySelector('#rememberMe')
  }

  確認モーダル(): Element {
    return this.container.querySelector('.modal-dialog')
  }

  確認モーダルタイトル(): Element {
    return this.container.querySelector('.modal-title')
  }

  確認モーダルメッセージ(): Element {
    return this.container.querySelector('.modal-message')
  }

  確認モーダルキャンセルボタン(): Element {
    return this.container.querySelector('.modal-cancel')
  }

  確認モーダルサブミットボタン(): Element {
    return this.container.querySelector('.modal-submit')
  }

  ログインボタン(): Element {
    return this.container.querySelector('.primary-button')
  }

  Email入力(value: string): void {
    fireEvent.change(this.Eメール入力エリア(), { target: { value } })
  }

  パスワード入力(value: string): void {
    fireEvent.change(this.パスワード入力エリア(), { target: { value } })
  }

  オートログインチェック(): void {
    fireEvent.click(this.オートログインチェックボックス())
  }

  確認モーダルキャンセルボタンクリック(): void {
    fireEvent.click(this.確認モーダルキャンセルボタン())
  }

  確認モーダルサブミットボタンクリック(): void {
    fireEvent.click(this.確認モーダルサブミットボタン())
  }

  ログインボタンクリック(): void {
    fireEvent.click(this.ログインボタン())
  }
}

describe('Login page', () => {
 // HTMLスナップショットのテスト
  it('matches snapshot', () => {
    const { asFragment } = render(<Login />, {})
    expect(asFragment()).toMatchSnapshot()
  })

 // 入力バリデーションのテスト
  describe('入力バリデーション', () => {
    it('Eメール未入力で、ログインボタンをクリックしたとき、Eメールが必須エラーとなること', async () => {
      const { container } = render(<Login />, {})
      const page = new Page(container)
      await act(async () => {
        page.ログインボタンクリック()
      })
      expect(page.Eメールエラーメッセージエリア().textContent).toEqual(
        '入力してください'
      )
    })
    it('Eメールをフォーマット誤りで入力のうえ、ログインボタンをクリックしたとき、Eメールがフォーマットエラーとなること', async () => {
      const { container } = render(<Login />, {})
      const page = new Page(container)
      await act(async () => {
        page.Email入力('test@test')
        page.ログインボタンクリック()
      })
      expect(page.Eメールエラーメッセージエリア().textContent).toEqual(
        'メールアドレスを入力してください'
      )
    })

...(中略)...

    it('パスワードを組み合わせ不足(アルファベット大文字不足)で入力のうえ、ログインボタンをクリックしたとき、パスワードがフォーマットエラーとなること', async () => {
      const { container } = render(<Login />, {})
      const page = new Page(container)
      await act(async () => {
        page.パスワード入力('ppppppppppppppp1?')
        page.ログインボタンクリック()
      })
      expect(page.パスワードエラーメッセージエリア().textContent).toEqual(
        'アルファベット(大文字小文字混在)と数字と特殊記号を組み合わせて8文字以上で入力してください'
      )
    })
  })

 // オートログインのテスト
  describe('オートログイン設定', () => {
    it('オートログイン未設定状態でオートログインチェックボックスをチェックしたとき、有効にする旨の確認ダイアログが表示されること', async () => {
      destroyCookie(null, 'rememberMe')
      const { container } = render(<Login />, {})
      const page = new Page(container)
      await act(async () => {
        page.オートログインチェック()
      })
      expect(page.確認モーダル()).toBeTruthy()
      expect(page.確認モーダルメッセージ().textContent).toEqual(
        '自動ログインを有効にしますか?'
      )
    })
    
...(中略)...

    it('自動ログイン無効の確認ダイアログが表示され、OKボタンを押したとき、自動ログイン設定がcookieに反映されること', async () => {
      setCookie(null, 'rememberMe', 'true')
      const { container } = render(<Login />, {})
      const page = new Page(container)
      await act(async () => {
        page.オートログインチェック()
      })
      expect(parseCookies(null).rememberMe).toEqual('true')
      await act(async () => {
        page.確認モーダルサブミットボタンクリック()
      })
      expect(parseCookies(null).rememberMe).not.toBeTruthy()
    })
  })

 // ログイン処理のテスト
  describe('ログイン確認', () => {
    it('Emailとパスワードに正しいフォーマットを入力の上、ログインボタンを押したとき、認証APIにID/PasswordをPutすること', async () => {
      const { container } = render(<Login />, {})
      const page = new Page(container)
      // http処理をモック化し、正常終了を返す
      axios.put = jest.fn().mockImplementation(() =>
        Promise.resolve({
          data: {},
        })
      )
      await act(async () => {
        page.Email入力('test@test.com')
        page.パスワード入力('Password1?')
        page.ログインボタンクリック()
      })
      // HTTP PUTの呼び出し回数の確認
      expect(axios.put).toBeCalledTimes(1)
      // HTTP PUTの入力引数の確認
      expect(axios.put).toBeCalledWith('/api/auth', {
        id: 'test@test.com',
        password: 'Password1?',
      })
    })

    it('ログイン時に、認証APIが認証エラー(401)を返すとき、認証エラーのアラートダイアログが表示されること', async () => {
      const { container } = render(<Login />, {})
      const page = new Page(container)
      // http処理をモック化し、認証エラーを返す
      axios.put = jest.fn().mockImplementation(() =>
        Promise.reject({
          response: {
            status: 401,
          },
        })
      )
      await act(async () => {
        page.Email入力('test@test.com')
        page.パスワード入力('Password1?')
        page.ログインボタンクリック()
      })
      // 認証エラーのモーダル表示確認
      expect(page.確認モーダル()).toBeTruthy()
      expect(page.確認モーダルタイトル().textContent).toEqual('認証エラー')
      expect(page.確認モーダルメッセージ().textContent).toEqual(
        'Emailもしくはパスワードが誤っています'
      )
    })

...(中略)...

  })
})