こんにちは、Gakken LEAP のフロントエンドエンジニアの Kusaka です。
最近、システムの内製化に伴いフロントエンドのテストを 0 から書く機会があったので、そのときに考えたことや実施したこと、学びをまとめようと思います。
経緯
この度、既存 WEB サービス(子ども向け教育サービス)を我々の環境へ移管することとなりました。
技術スタックとしてはフロントエンドは React/Next で作られており、バックエンドは Ruby on Rails が使われています。
皆さんも経験があるかもしれませんが、他のエンジニアが書いたコードを引き継いで追加開発や修正を行うのは大変です。コードをしっかり理解していないと、コードを変更した際の影響範囲がわからずどこかでリグレッションを起こしてしまうかもしれません。
すでに顧客もついているサービスですので下手に触って本番障害を出すわけにはいけません。しかし、参画したばかりなのでシステムの QA ができるほどの知識もないという状況です。
そのため、まずはテストを書いてシステム仕様のキャッチアップを行いつつ、リグレッションを検知するための仕組みを構築しようという結論になりました。
テスト戦略
フロントエンドのテストと一口に言っても、テストには様々な種類があります。
テストがカバーするコードの範囲によって分類すると主に下の4つに分けられます。
- 静的解析: コード実行を伴わないコードの品質、信頼性、および、セキュリティの検証
- 単体テスト: モジュール単体が提供する機能の検証
- 結合テスト: モジュールをつなげることで提供できる機能の検証
- E2E テスト: 実際のアプリケーション稼働状況に近い環境での機能の検証
E2E テストのようにカバーする範囲が大きければ本番環境に近いため忠実性が上がりますがメンテナンス工数や実行時間が増加します。これらのテストに対してどのようにコスト配分を行い最適化を行うかがテスト戦略における検討事項となります。
テストのコスト配分については、有名なテスト戦略モデルとしてテストピラミッドというものがあります。
単体テストといった下層のテストを多く書くことで安定した費用対効果の高いテスト戦略になるということが提唱されています。
一方でテスティングトロフィーというモデルもあり、こちらのモデルの方がフロントエンドにおけるテスト戦略においては多く採用されているようです。
理由として、フロントエンド開発においては、単体の UI コンポーネントだけで成立する機能は少なく、多くの場合様々なモジュールが組み合わさっているという背景があります。そのため、費用対効果の点からユーザー操作を起点とした結合テストを充実させることの方が単体テストを増やしていくよりもベターであるという考え方です。
今回のシステム内製化においてはテスティングトロフィーモデルに習い、結合テストから着手しました。
1つのテストケースでより多くのコードをカバーできる結合テストから進めていくのはリグレッションを検知するという目的に合致すると考えました。
一方、結合テストでは細かいコーナーケース等は対象外になるのですが、こちらは既存のコードに手を加えるタイミングで都度単体テストを追加していくことでカバーしようと考えました。
テスト環境の構築
テストツールも様々なものがありますが、今回は Jest と Testing Library を使うことにしました。
導入したパッケージは以下の通りです。
jest
- jest
- jest-environment-jsdom
- @types/jest
testing library
- @testing-library/jest-dom
- @testing-library/react
- @testing-library/user-event
mock
- next-router-mock
- identity-obj-proxy
babel
- babel-jest
- @babel/core
- @babel/preset-env
- @babel/preset-react
- @babel/preset-typescript
そしてルートディレクトリに jest.config.js と.babelrc を作成し、以下のように記述しました。
// jest.config.js
module.exports = async () => {
return {
testEnvironment: "jest-environment-jsdom",
moduleDirectories: ["node_modules", "<rootDir>"],
collectCoverage: true,
coverageDirectory: "coverage",
moduleNameMapper: {
"^.+\\.(css|less|scss)$": "identity-obj-proxy",
},
};
};
// .babelrc
{
"presets": [
"@babel/preset-env",
[
"@babel/preset-react",
{
"runtime": "automatic"
}
],
"@babel/preset-typescript"
]
}
そして、package.json の scripts に以下の一文を追記しました。
"test": "jest",
上記のように設定することで、test コマンドにてテストを実行することができるようになります。カバレッジレポートを出力する設定も行っているため実行時にカバレッジも確認できます。
モックの書き方
さて、テストのセットアップが完了して結合テストを書いていくのですが、結合テストを書くうえで大きな課題があります。それはデータの準備です。
API で取得したデータをもとに表示を変更するコンポーネントがあるとします。実際の API サーバーをテスト用に動かすのは時間や手間がかかりますし、異常系のテストを行う場合も面倒です。
ここで役に立つのがモックです。取得したデータの代用品として使われます。結合テストではモジュール間の連携が必要なためモックを使いこなすことがより重要になります。
Jest でモックを活用する場合は jest.mock や jest.spyon といった API を利用します。
例えば、ユーザーのサインインを行うコンポーネントがあり、libs/api/auth というファイルを import し signIn メソッドを通して api コールを行っているとします。この場合、まず
jest.mock("libs/api/auth");
と書くことにより、libs/api/auth モジュールをモック化することができます。
そして、signin メソッドの返り値を指定する場合以下のように書きます。
import * as auth from "libs/api/auth";
jest.spyOn(auth, "signIn").mockResolvedValue({
data: {
id: "1",
},
headers: {
"access-token": "your-access-token",
client: "your-client",
uid: "your-uid",
},
status: 200,
statusText: "OK",
config: {},
});
このようにすることで、テスト中に呼ばれる libs/api/auth の signIn メソッドの返り値を代替することができ、バックエンドを立ち上げずともテストを行うことができます。
実際のテスト
以下は signin を行うページの結合テストです。
// 準備のコード
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import userEvent from "@testing-library/user-event";
import mockRouter from "next-router-mock";
import Signin from "pages/auth/signin";
import { AuthContext } from "contexts/AuthProvider";
import { NotificationContext } from "contexts/NotificationProvider";
import * as auth from "libs/api/auth";
jest.mock("next/router", () => jest.requireActual("next-router-mock"));
jest.mock("libs/api/auth");
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.restoreAllMocks();
});
function setup() {
const user = userEvent.setup();
function mockSignIn(status = 200) {
if (status > 299) {
return jest.spyOn(auth, "signIn").mockRejectedValueOnce({
response: {
data: {
errors: {
0: "メールアドレスまたはパスワードが間違っています。",
},
},
},
});
}
return jest.spyOn(auth, "signIn").mockResolvedValueOnce({
data: {
id: "1",
},
headers: {
"access-token": "your-access-token",
client: "your-client",
uid: "your-uid",
},
status: 200,
statusText: "OK",
config: {},
});
}
const authContextValue = {
setIsSignedIn: jest.fn(),
setCurrentUser: jest.fn(),
isSignedIn: false,
currentUser: null,
handleGetCurrentUser: jest.fn(),
handleSignOut: jest.fn(),
};
const notificationContextValue = {
createErrorNotification: jest.fn(),
showNotification: false,
setShowNotification: jest.fn(),
type: null,
message: "",
createSuccessNotification: jest.fn(),
clearNotification: jest.fn(),
};
render(
<AuthContext.Provider value={authContextValue}>
<NotificationContext.Provider value={notificationContextValue}>
<Signin />
</NotificationContext.Provider>
</AuthContext.Provider>
);
const emailInput = screen.getByRole("textbox", { name: "メールアドレス" });
const passwordInput = screen.getByLabelText("パスワード");
const submitButton = screen.getByRole("button", { name: "ログインする" });
async function typeEmail(email) {
await user.type(emailInput, email);
}
async function typePassword(password) {
await user.type(passwordInput, password);
}
async function clickSubmit() {
await user.click(submitButton);
}
return {
authContextValue,
notificationContextValue,
typeEmail,
typePassword,
clickSubmit,
mockSignIn,
};
}
// 実行のコード
describe("Submit時の処理", () => {
test("signinフォームが正常に送信されるとsetIsSignedIn、setCurrentUserが呼び出され、mypageに遷移すること", async () => {
const {
authContextValue,
typeEmail,
typePassword,
clickSubmit,
mockSignIn,
} = setup();
mockSignIn();
await typeEmail("test@test.com");
await typePassword("password");
await clickSubmit();
// 評価のコード
expect(auth.signIn).toHaveBeenCalledWith({
email: "test@test.com",
password: "password",
});
expect(authContextValue.setIsSignedIn).toHaveBeenCalledWith(true);
expect(authContextValue.setCurrentUser).toHaveBeenCalledWith({ id: "1" });
expect(mockRouter).toMatchObject({ pathname: "/mypage" });
});
// 実行のコード
test("エラーが発生したらcreateErrorNotificationが呼び出されること", async () => {
const {
notificationContextValue,
typeEmail,
typePassword,
clickSubmit,
mockSignIn,
} = setup();
mockSignIn(401);
await typeEmail("test@test.com");
await typePassword("password");
await clickSubmit();
// 評価のコード
expect(
notificationContextValue.createErrorNotification
).toHaveBeenCalledWith("メールアドレスまたはパスワードが間違っています。");
});
});
テストの基本の流れは準備->実行->評価です。
- 準備: データの準備を行い、テスト対象のレンダリングを行います。
- 実行: レンダリングされた DOM 要素へテストケースに沿った操作を実行します。
- 評価: 実行結果が期待したものかどうか評価します。
上記のコード内で準備は setup 関数にまとめています。テストケースごとにデータを用意する必要がありますが、共通する処理も多いので関数に切り出すと便利です。
実行フェイズでは testing-library の userEvent を使い、ボタンのクリックや文字のタイプ等、ユーザーの操作を模倣して操作を実行しています。
評価は expect を活用します。今回のケースの場合、submit が押下された結果、onSubmit 内で呼ばれている signIn メソッドがフォームに入力されたデータを引数に呼ばれたか、state の setter が呼ばれたか、url が更新されるかを検証しています。
またエラーパターンのテストも書いています。こちらはエラーを返すようにモックを実装することで行えるようになります。
上記のような結合テストを全ページ分正常系をカバーするように書きました。テストカバレッジは Stmt80%以上を基準としました。
細かい部分はカバーできていないかと思いますが、ページの操作ができず、ユーザーが行動不能になるといった致命的なリグレッションの検知が期待できます。
今後、開発を進めていく中でテストは拡充させていこうと考えています。
実行時のポイント
実行時のポイントとして、要素を取得するときはできる限りアクセシブルネームを利用することが挙げられます。
アクセシブルネームで取得する際は以下のように書きます。
const emailInput = screen.getByRole("textbox", { name: "メールアドレス" });
上記の name は input フィールドに紐づけられたラベルの文言とマッチします。
アクセシブルネームは、視覚障害者やその他の障害を持つユーザーにとって、ウェブページやアプリケーションが理解しやすく、操作しやすくなるようにデザインされた要素の名前や説明のことを指します。
テストを実行する際は、できるだけ実際のユーザーの操作に近づけた方が良いので、画面上に表示される文言等をベースに要素を取得することが望ましいとされています。
テストを書いてみて感じたこと
一番良かったと感じたのがシステムの理解が大幅に進んだことです。コードを読むだけではなく実際に動かすことでどのような操作ができるページでどのようなコンポーネントが使われているのかといったことの解像度が高まりました。
普段行っているコーディングと異なる部分も多く慣れるまでは時間がかかったのですが、一度理解すれば短時間で書けるようになりました。今後はテスト駆動開発も取り入れていきたいです。
Gakken LEAP では教育をアップデートしていきたいエンジニアを絶賛大募集しています!!
参考文献
吉井健文(2023). 「フロントエンド開発のためのテスト入門 今からでも知っておきたい自動テスト戦略の必須知識」.翔泳社