📚

ランディングページ(LP)にユニットテストを導入した話

2024/05/23に公開

こんにちは!アルダグラムでエンジニアをしている柴田です。

弊社では、ランディングページ(以下、LP)にGatsby.js を利用しています。
今回は、LP に対してユニットテストを導入した話をしたいと思います。

背景

私が所属するチームでは、普段の開発に加え、LPの開発と運用も行っています。LPはプロダクト・組織の広報において非常に重要な役割を果たしています。例えば、新機能や導入事例のプレスリリース、企業ブランディングの情報発信の場として機能しています。 しかし、静的ページがある故に、十分なテストが整備されていませんでした。

さらにサービスの拡大に伴い、LPへの改修頻度が高まり、手動での確認作業が運用上の負担となってきました。SEO対策によりユーザー流入を増やし、広告コストを抑えることも視野しているため、より効率的な運用が求められるようになりました。

そこで、テストの棚卸しをチーム内で行い、テストで担保したい箇所を定めました。
定めた結果、LPの各ページに存在しているメールフォームで使用されているロジックを担保すべきとなりました。

LPの各ページに存在しているメールフォームで使用されているロジックには

  • バリデーション
  • 入力されたメールアドレスを元に、APIリクエスト送信を行う処理

と大きく分けて2つのロジックが存在していました。
上記のロジックを担っている関数は外部に切り出されて、容易にユニットテストが可能な状態で定義されてました。

対応したこと

1. ユニットテスト導入

ユニットテスト導入に際し、まずはライブラリのインストールと設定に取り掛かりました。

ライブラリはjestを採用しました。
jestの設定は、package.jsonに記述しました。

jestの設定詳細については、公式ドキュメントを参照ください。
https://jestjs.io/ja/docs/configuration

コード例
package.json
"scripts": {
  "unit:test": "jest --no-cache"
}
"jest": {
  "preset": "ts-jest",
  "testEnvironment": "jest-environment-jsdom",
  "testMatch": [
    "**/*.spec.ts"
  ],
  "roots": [
    "<rootDir>/src/"
  ],
  "moduleNameMapper": {
      "@/(.*)$": "<rootDir>/$1",
      "@core(.*)$": "<rootDir>/src/core/$1",
      "@ui(.*)$": "<rootDir>/src/ui/$1",
      "@lib(.*)$": "<rootDir>/src/lib/$1",
      "@config(.*)$": "<rootDir>/src/config/$1",
      "@images(.*)$": "<rootDir>/src/theme/$1"
  },
  "setupFilesAfterEnv": [
    "./tests/setup-i18n.ts"
  ]
}

setupFilesAfterEnv で設定している setup-i18n.ts ファイルの中身を例示すると、以下になります。

setup-i18n.ts
// i18nのmockのコード
jest.mock('i18next', () => ({
  // t関数をモックして、任意の文字列を返すように設定
  init: () => {},
  use: () => {},
  t: jest.fn(str => str)
}))

// react-i18nextのmockのコード
jest.mock('react-i18next', () => ({
  useTranslation: () => {
    return {
      t: (str: string) => str,
      i18n: {
        changeLanguage: () => new Promise(() => {})
      }
    }
  },
  initReactI18next: {
    type: '3rdParty',
    init: () => {}
  }
}))

このように、セットアップファイルを定義することで、テストファイルごとに mock 実装を書く必要がなくなります。非常に便利です。

続いて、テストコードの実装に着手していきました。

今回実装したテストコードは大きく分けて2つあります。

  • バリデーションのテスト
  • メールフォームのリクエスト送信のテスト

バリデーションのテストでは、以下のテスト観点で実装しました

  1. 必須フィールドチェック: 全ての必須フィールドが正しくチェックされているか確認
  2. 長さ制限: 最大文字数や最小文字数が正しくチェックされているか確認
  3. フォーマットチェック: メールアドレスが正しいフォーマットであるか確認
実際のユニットテストのコード一例
describe('ExecuteValidator', () => {
  it('should pass', async () => {
    const testEmailValue = 'test@example.com'
    expect(
      await ExecuteValidator(EmailValidator, { email: testEmailValue })
    ).toMatchObject({})
  })
  it('should fail when email value is undefined', async () => {
    const testEmailValue = undefined
    expect(
      await ExecuteValidator(EmailValidator, { email: testEmailValue })
    ).toMatchObject({ email: 'この項目は必須です' })
  })
  it('should fail when email value is empty', async () => {
    const testEmailValue2 = ''
    expect(
      await ExecuteValidator(EmailValidator, { email: testEmailValue2 })
    ).toMatchObject({ email: 'この項目は必須です' })
  })

  it('should fail when email value is not email format', async () => {
    const testEmailValue = 'test'
    expect(
      await ExecuteValidator(EmailValidator, {
        email: testEmailValue
      })
    ).toMatchObject({ email: '有効なメールアドレスではありません' })
  })
})

メールフォームのリクエスト送信のテストでは、以下のテスト観点で実装しました

  1. 正しいデータの送信:
    APIへリクエストデータが期待通りした通りに送信されているか

  2. 成功時の挙動:
    APIへリクエストが成功した時の関数の返り値が期待通りであるか

    コード一例
    import { describe, expect } from '@jest/globals'
    import * as exampleForm from '@core/hooks/exampleForm'
    
    jest.mock('axios')
    
    import axios, { AxiosResponse } from 'axios'
    // APIコールにaxiosを使っているため、axiosのmockを定義
    const mockedAxios = axios as jest.Mocked<typeof axios> & {
      mockResolvedValue: jest.Mock
      mockRejectedValue: jest.Mock
    }
    
    describe('exmaple api request check', () => {
      describe('should pass', () => {
        afterEach(() => {
          // 1回のテストが終わるたびに、refresh
          jest.clearAllMocks()
          jest.restoreAllMocks()
        })
        const testEmailValue = 'test@example.com'
    
        // ・仕様
        // 255が境界値、256文字以上だとエラーになる
        // 空文字の場合、cookieにセットされず、APIのリクエストに含まれない
        test.each`
          email             | identifier         | utm_source         | utm_medium         | utm_campaign
          ${testEmailValue} | ${''}              | ${''}              | ${''}              | ${''}
          ${testEmailValue} | ${'i'.repeat(255)} | ${''}              | ${''}              | ${''}
          ${testEmailValue} | ${''}              | ${'s'.repeat(255)} | ${''}              | ${''}
          ${testEmailValue} | ${''}              | ${''}              | ${'m'.repeat(255)} | ${''}
          ${testEmailValue} | ${''}              | ${''}              | ${''}              | ${'c'.repeat(255)}
          ${testEmailValue} | ${'i'.repeat(255)} | ${'s'.repeat(255)} | ${'m'.repeat(255)} | ${'c'.repeat(255)}
          ${testEmailValue} | ${'i'.repeat(256)} | ${'s'.repeat(256)} | ${'m'.repeat(256)} | ${'c'.repeat(256)}
        `(
          'should pass',
          async ({ email, identifier, utm_source, utm_medium, utm_campaign }) => {
            if (identifier || utm_source || utm_medium || utm_campaign) {
              const mockParameters = {
                identifier,
                utm_campaign,
                utm_medium,
                utm_source
              }
              // 広告パラメータをcookieにセット
              Object.entries(mockParameters).forEach(([key, value]) => {
                document.cookie = `${key}=${value}`
              })
            }
    
            const mockApiResponse = {
              status: 200,
              statusText: 'OK',
              headers: { 'content-type': 'application/json' },
              config: {} as unknown as AxiosResponse['config'],
              data: {
                data: {
                  dummyUser: {
                    dummyHogeUser: {
                      hogeToken: 'testInviteToken'
                    }
                  }
                }
              }
            }
    
            // レスポンスをモック
            await mockedAxios.mockResolvedValue(mockApiResponse)
    
            const expectQuery = `mutation DummyQueryName {
              dummyMutationA(input: {
                dummyInput: {
                  email: "${email}",
                  identifier: "${identifier}"
                  utmSource: "${utm_source}",
                  utmMedium: "${utm_medium}",
                  utmCampaign: "${utm_campaign}",
                }
              }) {
                略
              }
            }`
    
            await exampleForm.registerCheck({
              email: email
            })
    
            // axiosが1回呼ばれたことを確認
            expect(mockedAxios).toBeCalledTimes(1)
            // axiosが指定の引数、オブジェクトで呼ばれたことを確認する
            expect(mockedAxios).toBeCalledWith({
              url: exampleForm.graphqlUrl,
              method: 'post',
              data: {
                query: expectQuery
              }
            })
          }
        )
      })
    })
    
  3. 失敗時の挙動:
    APIへリクエストが失敗した時のエラーメッセージの返り値、エラーハンドリングが期待通りした通りに送信されているか

    コード一例
    import { describe, expect } from '@jest/globals'
    import * as exampleForm from '@core/hooks/exampleForm'
    
    jest.mock('axios')
    
    import axios, { AxiosResponse } from 'axios'
    // APIコールにaxiosを使っているため、axiosのmockを定義します
    const mockedAxios = axios as jest.Mocked<typeof axios> & {
      mockResolvedValue: jest.Mock
      mockRejectedValue: jest.Mock
    }
    
    describe('exmaple api request check', () => {
      describe('should fail', () => {
        // 
        const mockParameters = {
            identifier: 'i'.repeat(255),
            utm_campaign: 'c'.repeat(255),
            utm_medium: 'm'.repeat(255),
            utm_source: 's'.repeat(255)
        }
        const testEmailValue = 'test@example.com'
        const expectQuery = `mutation DummyQueryName {
              dummyMutationA(input: {
                dummyInput: {
                  email: "${testEmailValue}",
                  identifier: "${mockParameters.identifier}"
                  utmSource: "${mockParameters.utm_source}",
                  utmMedium: "${mockParameters.utm_medium}",
                  utmCampaign: "${mockParameters.utm_campaign}",
                }
              }) {
                略
              }
            }`
        beforeEach(() => {
          // 広告パラメータはcookieにセットしているので、テストでも事前にセットする
          Object.entries(mockParameters).forEach(([key, value]) => {
            document.cookie = `${key}=${value}`
          })
        })
        afterEach(() => {
          // 1回のテストが終わるたびに、refresh
          jest.clearAllMocks()
          jest.restoreAllMocks()
        })
    
        it('when network timeout error', async () => {
          const mockApiResponse = {
            status: 503,
            statusText: 'Service Unavailable',
            headers: { 'content-type': 'application/json' },
            config: {} as unknown as AxiosResponse['config'],
            data: {
              errors: [
                {
                  message: 'Service Unavailable'
                }
              ]
            }
          }
    
          // 指定したエラーレスポンス(mockApiResponse)で失敗するように設定
          mockedAxios.mockRejectedValue(mockApiResponse)
    
          const result = await exampleForm.registerCheck({
            email: testEmailValue
          })
    
          // axiosが1回呼ばれたことを確認
          expect(mockedAxios).toBeCalledTimes(1)
          // axiosが指定の引数、オブジェクトで呼ばれたことを確認する
          expect(mockedAxios).toBeCalledWith({
            url: uexampleForm.graphqlUrl,
            method: 'post',
            data: {
              query: expectQuery
            }
          })
          // 関数の返り値がエラーオブジェクトが返ってくることを確認
          expect(result).toMatchObject({
            type: 'error',
            message: '問題が発生しました。しばらくたってからお試しください。'
          })
        })
      })
    })
    

2. Github Actionsによるテスト自動化

Github Actionsを使って、PR作成時に単体テストが実行されるようにしました。
これにより、ユニットテストで自動で行ってくれるので、実装による実行漏れを抑制でき品質を担保できます。

テスト実行タイミングは

  • PRが作成された時、
  • PRが再オープンされた時
  • PRが更新された時

でテストを自動で実行するようにしました。
以下が、その時のGithub workflowのコードです。

name: Unit Test

on:
  pull_request:
    types: [opened, reopened, synchronize]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
       # ブランチのチェックアウト
      - name: Checkout
        uses: actions/checkout@v3

      # Node.jsのセットアップ
      - name: Set up Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '20.x'
          cache: npm

      - name: Cache node_modules
        uses: actions/cache@v3
        id: node_modules_cache_id
        env:
          CACHE_NAME: cache-node-modules
        with:
          path: 'node_modules'
          key: ${{ runner.os }}-build-${{ env.CACHE_NAME }}-${{ hashFiles('package-lock.json') }}

	    # テスト実行
      - name: Install dependencies and Run unit tests
        run: |
          npm ci
          npm run unit:test

試しにサンプルでPRを作ってみると、期待通りテストが実行されているのが確認できます。
CI成功時

また、Mainとdevelopの向けのPRには、GitHub の branch protection のルール設定でテストが通らない限り、PRをマージできない設定も合わせて行いました。テストが失敗したPRがマージされることを防ぐためです。
CI実行中_マージ不可

導入してみて

ここまでご覧いただきありがとうございます!

ユニットテストを導入したことで、対象のロジックに対してテストがかけられ、品質を担保できるようになりました。今後もテストカバレッジの拡大や自動化の強化を図り、さらなる品質向上と開発効率の向上を目指していけるよう、引き続き改善していきます 💪

フロントエンドのテスト導入を考えている方やランディングページ(LP)を運用している方にとって、少しでも参考になったら幸いです 😄

アルダグラム Tech Blog

Discussion