はじめに
早いもので2023年も終わりが近づいてきました。皆様はいかがお過ごしでしょうか。毎年アドベントカレンダーの時期になると、今年一年で学び経験した技術の知識を棚卸しするために記事を書いています。2023年はよくテストを書いた年でした。React のテストコードで学んだことを書き記します。
若手の頃、大先輩に「良いブレーキがついているからこそ安心してアクセルを踏める」という言葉を頂いたことは今でも大事にしています。
テストにはいわゆる手動テストと自動テストがあります。どちらも必要ですし、用いる手法はケースバイケースなのかなと思います。テストにかける時間や労力は短期的に見るか長期的に見るかで手動テストと自動テストのどちらが良いかは一概には言えません。多くの場合は組み合わせて行うと思います。
テストレイヤーの切り方として、Ice-Cream Cones, Testing Pyramids, Testing Trophy など様々な考え方があります。上層ほど高コスト・低速。下層ほど低コスト・高速と言われています。
出典)Ice-Cream Cones, Testing Pyramids, Testing Trophy, Testing Library の開発者 Kent C. Dodds より引用加筆
React を用いたフロントエンド開発ではデザインされたUIコンポーネント部品(ボタンやフォームなど)を組み合わせて Web サイトを構築します。例えば「オレンジ色の決定ボタン」という部品をコンポーネントとして実装します。このコンポーネントの Unit Test を書こうとすると決定ボタンはオレンジ色であることを確認することになり、あまり意味のあるテストではありません。The Testing Trophy の考え方を示した Kent C. Dodds 氏は React コンポーネントの実装の詳細をテストするのではなく、 React アプリケーションの利用者としてテストをするという考え方で Integration Test に厚みをもたせるべきと言っています。
出典)React で紐解くモダンフロントエンド開発の歴史と進歩
2023年は React のテストを Testing Trophy の考え方に沿って進め Jest, React Testing Library でたくさんのテストコードを書きました。これらの Tips や学びを書き記します。
Jest
Jest は JavaScript のテスティングフレームワークです。ゼロコンフィグ、スナップショット、モック、カバレッジ、、、等の豊富な機能を持ちつつもシンプルなことが特徴です。Testing Trophy では Unit Test のレイヤーのテストを書くことが多いです。
特にコードカバレッジの機能は最もお世話になりました。フラグ --coverage
を指定することで、コードカバレッジを生成します。網羅率を元にテストが足りていない箇所が明確になるので指標として用いていました。
命令網羅 (statement coverage) (C0) → Stmts の列
分岐網羅 (branch coverage) (C1) → Branch の列
出典)Jest 公式サイト
出典)ホワイトボックステストにおけるカバレッジ(C0/C1/C2/MCC)について
まずはテストケースを洗い出す
開発状況によって一長一短がありますが、開発を終えてからテストをするよりも設計時点で見えているテストケースは開発するタイミングと同時にテストケースとして洗い出しておくのが個人的には良いと思っています。以下のようにケースだけは洗い出しておいて失敗させるだけのテストコードを書いていました。
describe('A1_基本情報画面 > 画面項目定義', () => {
test('[正常系][A1_1] 初期表示でカテゴリ名が表示されている', async () => {
expect(false).toBeTruthy();
});
test('[正常系][A1_2] 初期表示でコンテンツ名が表示されている', async () => {
expect(false).toBeTruthy();
});
}
ただチームによってはCI/CDパイプラインの中で自動テストを組んだりすると思います。保守期間中は自動テストは全て成功していることを前提にしたパイプラインであってほしいが、開発期間中はテストコード実装前なので失敗するだけのコードがある時には失敗を許容するなど、状況によってパイプラインを直すこともあると思います。ケースバイケースなので柔軟に対応する他ないかなと思います。パイプラインの仕組みだけでも一記事くらい書けてしまいそうなので本記事ではこの辺りで留めます。
日本語でテストケース書くと部分的にテスト実行しにくいのですが、ケースを一覧並べた時に日本語の方が日本人にはわかりやすいかなと思います。
モック関数を用いて小さい単位でテストする
前述の Testing Trophy の考え方に沿うと、プログラムを繋げた状態でテストをするほどコストが高くなります。プログラムを繋げた状態でのテストはプログラムが最終形に近いのでわかりやすいというメリットもありますが、以下のようなデメリットもあります。
・テストを開始できるのは開発が全て終えてからになる
・異常系のテスト実施が難しくなる
・問題の切り分けが難しくなる
できるだけ小さい単位でテストを行うためにモック関数を用います。
モック関数によりコード間の繋がりをテストすることができます。 関数が持つ実際の実装を除去したり、関数の呼び出し(また、呼び出しに渡されたパラメータも含め)をキャプチャしたり、new によるコンストラクタ関数のインスタンス化をキャプチャできます。 そうすることでテスト時のみの返り値の設定をすることが可能になります。
まずは、公式ガイドラインに書かれている内容を転記引用します。
テスト対象のプロダクトコード
この関数は、与えられた配列の各要素に対して、コールバック関数を呼び出します。
export function forEach(items, callback) {
for (let index = 0; index < items.length; index++) {
callback(items[index]);
}
}
テストコード
この関数をテストするために、モック関数を利用して、コールバックが期待通り呼び出されるかを確認するためにモックの状態を検証することができます。
const forEach = require('./forEach');
const mockCallback = jest.fn(x => 42 + x);
test('forEach mock function', () => {
forEach([0, 1], mockCallback);
// モック関数は2回呼び出されること
expect(mockCallback.mock.calls).toHaveLength(2);
// 関数の最初の呼び出しの第一引数は0であること
expect(mockCallback.mock.calls[0][0]).toBe(0);
// 関数の2回目の呼び出しの最初の引数は1であること
expect(mockCallback.mock.calls[1][0]).toBe(1);
// 関数への最初の呼び出しの戻り値は42であること
expect(mockCallback.mock.results[0].value).toBe(42);
});
このように Jest のモック関数を用いることで、小さい単位でテストを行っていくことが出来ます。小さい単位でプログラムを書くとバグも気が付きやすく、品質を積み重ねていくことが出来ます。
Jest が指すモックは広義のモックです。mock, spy, stub の関係性をきれいに整理して頂いているので以下の記事は大変勉強になりました。
テストの前処理、後処理の実行タイミング
Jest にはテストの前処理、後処理を行うための関数があります。例えば接続先 API のモックデータのクリアなどで用います。
通常は describe の中では beforeAll, beforeEach, test, afterEach, afterAll の順に実行されます。ただし、describe をネストさせると以下のようになります。*1,2 の箇所がすぐには気が付かないところかなと思いますのでご注意下さい。
describe("describe layer 1", () => {
beforeAll(() => console.log("describe layer 1 > beforeAll"));
beforeEach(() => console.log("describe layer 1 > beforeEach"));
afterEach(() => console.log("describe layer 1 > afterEach"));
afterAll(() => console.log("describe layer 1 > afterAll"));
test("describe layer 1 > test", () => console.log("describe layer 1 > test"));
describe("describe layer 2", () => {
beforeAll(() => console.log("describe layer 2 > beforeAll"));
beforeEach(() => console.log("describe layer 2 > beforeEach"));
afterEach(() => console.log("describe layer 2 > afterEach"));
afterAll(() => console.log("describe layer 2 > afterAll"));
test("describe layer 2 > test", () => console.log("describe layer 2 > test"));
});
});
$ jest describe.test.js
describe layer 1 > beforeAll
describe layer 1 > beforeEach
describe layer 1 > test
describe layer 1 > afterEach
describe layer 2 > beforeAll
describe layer 1 > beforeEach (*1)
describe layer 2 > beforeEach
describe layer 2 > test
describe layer 2 > afterEach
describe layer 1 > afterEach (*2)
describe layer 2 > afterAll
describe layer 1 > afterAll
PASS ./describe.test.js
describe layer 1
✓ describe layer 1 > test (15 ms)
describe layer 2
✓ describe layer 2 > test (19 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 3.158 s
Ran all test suites matching /describe.test.js/i.
非同期テストには async/await を用いる
非同期テストには async/await を用います。規定のタイムアウト時間は5000msですが、test()
の第三引数にて個別にタイムアウト時間を設定できます。setTimeout()
で共通のタイムアウト時間を変更できます。
test('[正常系] API は正常応答', async () => {
const data = await fetchData();
expect(data).toBe('success');
}, 30000);
test('[異常系] API は異常応答', async () => {
expect.assertions(1);
try {
await fetchData();
} catch (e) {
expect(e).toMatch('error');
}
}, 30000);
React Testing Library
React Testing Library を用いて Testing Trophy の Integration Test のレイヤーのテストを書きます。DOM の要素を探して期待値と比較するようにテストコードを書くことでユーザー操作に沿ったテストが実施できます。
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom'
import Fetch from './fetch'
test('loads and displays greeting', async () => {
// テスト対象を DOM にレンダリングする
render(<Fetch url="/greeting" />)
// ユーザ操作をテストコードで再現
await userEvent.click(screen.getByText('Load Greeting'))
await screen.findByRole('heading')
// 期待値と比較しテストする
expect(screen.getByRole('heading')).toHaveTextContent('hello there')
expect(screen.getByRole('button')).toBeDisabled()
})
出典)React Testing Library 公式サンプル
data-testid を用いる
React Testing Library では DOM の要素を検索してテストコードを書くのですが、データの状態によって変わってしまうテキストやラベルでは安定したテストを書けません。そこでテスト対象のプロダクトコードに data-testid
を付与することでテスト対象を明確に指定することが出来ます。
<div data-testid="custom-element" />
import {render, screen} from '@testing-library/react'
render(<CustomComponent />)
const element = screen.getByTestId('custom-element')
Mock Service Worker と一緒に使う
Mock Service Worker とは REST API をモック化するツールです。React Testing Library で Integration Test を行うとはいえ、手動の操作で React の画面を確認せずに開発を行うことは無いでしょう。外部の API をモック化してレスポンスデータを制御します。
Mock Service Worker は React Testing Library と相性がよくテストコードの中でも利用できます。手動の操作で目で見て想定通り動いていることを確認し、テストコードに落としていくことになります。手動の操作とテストコードのデータパターンが一致するように Mock Service Worker のハンドラをうまくパターン別に実装していけるとよいでしょう。
import { rest } from 'msw'
export const handlers = [
rest.get('/contents/:id', async (req, res, ctx) => {
switch(req.params.id){
case "1":
return res(
ctx.status(200),
ctx.json({
id: 1,
title: "This is Content 1"
})
);
case "2":
return res(
ctx.status(404),
ctx.json({
code: "E404",
message: "data is not found"
})
);
default:
return res(
ctx.status(200),
ctx.json({
id: 999,
title: "This is Content 999"
})
);
}
}),
];
hook を適切に分けることでテストコードが書きやすくなる
hook に限りませんが関数を適切に分けることでテストコードが書きやすくなります。React をやっていると何でもかんでも画面描画の実装で処理しがちです。標準化やコードレビューによって適切に関数を分けることはテストコードの書きやすさ、品質の向上に繋がります。
redux / store の状態は都度クリアした方がよい
状態管理は様々なツールがあります。私は Redux を用いました。テストコードは依存関係が無いようにしておいた方が良いです。データの状態が他のテストケースに依存していると問題の切り分けに時間がかかりますので redux / store の状態は都度クリアすることをおすすめします。
CI/CD パイプライン
GitLab Flow に沿って CI/CD パイプラインを構築しました。
CI:静的コード解析とテストを担う
CD:各環境へのビルドとデプロイを担う
という役割分担の下で以下のようにパイプラインを組みました。
Commit : ローカルPC で lint や prettier を husky 等のタスクランナーで毎回実施する(CI)
Push : リモートリポジトリ(Git, Bitbucket等)でテストコードを毎回実施する(CI)
Pull Request : PR の承認・マージ後に各環境へのビルドとデプロイを行う(CD)
当初は Commit や Push の色分けは無く CI を回していましたが、人数の増加や開発時期によって困ることが増えたので現在の形に至りました。この辺りはまだまだ改善の余地がありそうです。
おわりに
今後もテストコードを改善していきたいと思っています。例えば以下のようなことができれば良いなと思っています。
・テストコード自体の標準化
・Create React App から Vite や Next.js へ変わっていく流れが強まっているので Vitest への移行
・画面をまたぐテストは E2E テストの方が良いと思いますので Playwright も導入したいです
以上、2023年を振り返り React のテストコードを書く中で得た学びを簡単な Tips としてまとめてみました。来年のアドベントカレンダーでも得た知識を棚卸ししたい思っています。どなたかのお役に立てれば嬉しいです。