ランディングページ(LP)にユニットテストを導入した話
こんにちは!アルダグラムでエンジニアをしている柴田です。
弊社では、ランディングページ(以下、LP)にGatsby.js を利用しています。
今回は、LP に対してユニットテストを導入した話をしたいと思います。
背景
私が所属するチームでは、普段の開発に加え、LPの開発と運用も行っています。LPはプロダクト・組織の広報において非常に重要な役割を果たしています。例えば、新機能や導入事例のプレスリリース、企業ブランディングの情報発信の場として機能しています。 しかし、静的ページがある故に、十分なテストが整備されていませんでした。
さらにサービスの拡大に伴い、LPへの改修頻度が高まり、手動での確認作業が運用上の負担となってきました。SEO対策によりユーザー流入を増やし、広告コストを抑えることも視野しているため、より効率的な運用が求められるようになりました。
そこで、テストの棚卸しをチーム内で行い、テストで担保したい箇所を定めました。
定めた結果、LPの各ページに存在しているメールフォームで使用されているロジックを担保すべきとなりました。
LPの各ページに存在しているメールフォームで使用されているロジックには
- バリデーション
- 入力されたメールアドレスを元に、APIリクエスト送信を行う処理
と大きく分けて2つのロジックが存在していました。
上記のロジックを担っている関数は外部に切り出されて、容易にユニットテストが可能な状態で定義されてました。
対応したこと
1. ユニットテスト導入
ユニットテスト導入に際し、まずはライブラリのインストールと設定に取り掛かりました。
ライブラリはjest
を採用しました。
jest
の設定は、package.jsonに記述しました。
jestの設定詳細については、公式ドキュメントを参照ください。
コード例
"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"
]
}
-
moduleNameMapper
- エイリアスやカスタムパスを簡潔に指定し、テスト対象のモジュールのパスの参照を容易するためのものです
- [例]
@ui(.*)$
は<rootDir>/src/ui/$1
というパスにマッピングするように jest に設定しています - 参考: https://jestjs.io/ja/docs/configuration#modulenamemapper-objectstring-string--arraystring
-
setupFilesAfterEnv
- jestのテスト環境をセットアップするコードを定義できます。
指定した各setupFileはテストファイルごとに1回実行されます。 - 共通のmockファイルを定義したいときに便利です。
- 参照: https://jestjs.io/ja/docs/configuration#setupfilesafterenv-array
- jestのテスト環境をセットアップするコードを定義できます。
setupFilesAfterEnv で設定している 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つあります。
- バリデーションのテスト
- メールフォームのリクエスト送信のテスト
バリデーションのテストでは、以下のテスト観点で実装しました
- 必須フィールドチェック: 全ての必須フィールドが正しくチェックされているか確認
- 長さ制限: 最大文字数や最小文字数が正しくチェックされているか確認
- フォーマットチェック: メールアドレスが正しいフォーマットであるか確認
実際のユニットテストのコード一例
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: '有効なメールアドレスではありません' })
})
})
メールフォームのリクエスト送信のテストでは、以下のテスト観点で実装しました
-
正しいデータの送信:
APIへリクエストデータが期待通りした通りに送信されているか -
成功時の挙動:
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 } }) } ) }) })
-
失敗時の挙動:
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を作ってみると、期待通りテストが実行されているのが確認できます。
また、Mainとdevelopの向けのPRには、GitHub の branch protection のルール設定でテストが通らない限り、PRをマージできない設定も合わせて行いました。テストが失敗したPRがマージされることを防ぐためです。
導入してみて
ここまでご覧いただきありがとうございます!
ユニットテストを導入したことで、対象のロジックに対してテストがかけられ、品質を担保できるようになりました。今後もテストカバレッジの拡大や自動化の強化を図り、さらなる品質向上と開発効率の向上を目指していけるよう、引き続き改善していきます 💪
フロントエンドのテスト導入を考えている方やランディングページ(LP)を運用している方にとって、少しでも参考になったら幸いです 😄
株式会社アルダグラムのTech Blogです。 世界中のノンデスクワーク業界における現場の生産性アップを実現する現場DXサービス「KANNA」を開発しています。 採用情報はこちら: herp.careers/v1/aldagram0508/
Discussion