「フロントエンド開発のためのテスト入門」を読むぜ
第1章 テストの目的と障壁
1-2 テストを書く目的
テストを書くことで以下のような利点を得られる。
- 積極的にリファクタリングできるようになる
- ライブラリのマイナーアップデートを取り込みやすい
- 責務が肥大化したコードに気づける
- 実装に対する補足情報を残せる
わかる。
1-3 テストを書く障壁
テストを書くことは長期的に見れば時間を節約できるという内容。
いやわかるんだけど、コンポーネントをちょこっと変更しただけで多くのテストコードを修正する必要があったりしてダルいので手放しに長期的にはお得とは言えなくない?とは思う。
第2章 テスト手法とテスト戦略
2-4 テスト戦略モデル
結合テストに比重を置く「テスティングトロフィー型」が紹介されている。
この辺の話は Testing Library の開発者である Kent C. Dodds さんのブログ (The Testing Trophy and Testing Classifications) にもいろいろ書いてあって読んでみると結構面白かった。 他の記事も面白そう。
2-5 テスト戦略計画
レスポンシブレイアウトなどのスタイルを含むテストは Testing Library では十分に対応できない。
Storybook でのコンポーネント単位のビジュアルリグレッションテストが有効とのこと。
第3章 はじめての単体テスト
Jest の環境構築と基本的なテストの書き方なのでさらっとだけ読んだ。
第4章 モック
4-1 モックを使用する目的
用語は混乱しがちなので用語整理があるのはすごく良い。
- スタブは代用が目的
- スパイは記録が目的
- Jest ではそれらをまとめてモックモジュールやモック関数と呼ぶ
4-2 モックモジュールを使ったスタブ
jest.mock()
を使った実装の置き換えについて説明されている。
jest.requireActual()
で本来の実装を import して一部を置き換えることもできる。
4-3 Web API のモック基礎
jest.spyOn()
を使用して API クライアントをモックする。
jest.spyOn()
はモック実装に対して TypeScript の型が効くのでテストコードの保守性が高くなる。
4-4 Web API のモック生成関数
jset.spyOn()
を呼び分けるユーティリティ関数を用いるパターン。
function mockGetMyArticles(status = 200) {
if (status > 299) {
return jest
.spyOn(Fetchers,"getMyArticles")
.mockRejectedValueOnce(httpError);
}
return jest
.spyOn(Fetchers,"getMyArticles")
.mockResolvedValueOnce(getMyArticlesData);
}
4-5 モック関数を使ったスパイ
jest.fn()
でモック関数を生成し、コールバックが呼ばれたかなどをテストする方法が説明されている。
モック関数は呼ばれた回数や渡された引数を記録できる。
4-6 Web API の詳細なモック
ファクトリー関数やユーティリティ関数を駆使して複数のテストパターンに対応する例。
この節は正直意味不明。 テストコードに不要なロジックを持ち込みすぎ。
4-7 現在自己奥に依存したテスト
Jest の Fake Timer を使用して時を止める例。
第5章 UI コンポーネントテスト
5-4 アイテム一覧 UI コンポーネントテスト
- 複数要素は
screen.getAllByRole()
で取得する -
within()
で取得範囲を要素内に絞る - 存在しないことをテストする場合は
queryByRole()
などを使用する -
toHaveAttribute()
で属性を持っているかをテストする
5-5 インタラクティブな UI コンポーネントテスト
userEvent で input 要素に入力し、getByDisplayValue()
で値が入力されているかをテストしている。 <input type="password" />
はロールを持たないので getByPlaceholderText()
などで取得するとよいらしい。
form 要素はアクセシブルネームが与えられた場合のみ form ロールを持つらしい。 そのためにこの例では h2 要素に id を与え、aria-labelledby
で h2 要素の id を指定することでアクセシブルネームとして引用させている。
import { useId } from "react";
export const Form = () => {
const headingId = useId();
return (
<form aria-labelledby={headingId}>
<h2 id={headingId}>新規アカウント登録</h2>
{/* do something... */}
</form>
);
};
これで getByRole("form")
で要素を取得できるようになる。 知らなかった。
5-6 ユーティリティ関数を使用したテスト
form に対する入力や操作を関数化して再利用する例。
5-7 非同期処理を含む UI コンポーネントテスト
「準備、実行、検証」の3ステップにまとめられたテストコードは Arrange-Act-Assert (AAA) パターンと呼ばれており、可読性が高いことが特徴です。
なるほど。
5-8 UI コンポーネントのスナップショットテスト
Jest の toMatchSnapshot()
を使用したスナップショットテストの例。 レンダリングされる HTML を比較する。
5-9 暗黙のロールとアクセシブルネーム
@testing-library/react の logRoles()
を使うことで、ロールやアクセシブルネームを確認することができる。
暗黙のロールの対応表が載っていて便利。
第6章 カバレッジレポートの読み方
jest-html-reporters というカスタムレポーターでは時間がかかっているテストを可視化したりもできるらしい。 便利。
第7章 Web アプリケーションの結合テスト
これ以降の章では「技術記事投稿/共有サービス」を題材として実践的なテストを解説している。
7-3 Next.js Router の表示結合テスト
Next.js の Router に関連するテストを書くために next-router-mock を使用する。
mockRouter.setCurrentUrl("/my/posts")
などで現在の URL を再現できる。
7-4 Next.js Router の操作結合テスト
URL パラメータが連動する UI をテストする例。
以下を項目をテストしている。
- URL パラメータによって UI の初期値が選択されること
- UI を操作すると URL パラメータが書き換えられること
7-6 Form のバリデーションテスト
input に入力する関数などを return する setup
関数を用意する。
入力のバリデーションに失敗するとバリデーションエラーが表示されることをテストする。
バリデーションエラーが表示されるまで時間がかかるため Testing Library の waitFor()
で所定時間の間アサートをリトライする工夫を施している。
7-8 Web API の結合テスト
MSW で API をモックした上で以下の内容のテストしている。
- AlertDialog 表示のテスト
- Toast 表示のテスト
- 画面遷移のテスト
結合テストでは子コンポーネントに委ねた処理はテストせず、親コンポーネントに書かれている連結部分に集中してテストを書くことで、責務境界がはっきりとした設計になると書かれている。 これはすごく大事だと思う。
7-9 画像アップロードの結合テスト
画像は AWS SDK 経由で AWS S3 に保存する実装で、開発環境では S3 互換のオブジェクトストレージサーバーである MinIO という開発用サーバーに保存されるらしい。 MinIO 知らなかった。
テストもそうだが実装自体も非常に勉強になる。
ここでは画像選択のブラウザ API と画像アップロード API をモックする方法を解説している。 前者はダミーの画像ファイルを作成し user.upload()
で画像選択のインタラクションを再現するユーティリティ関数を作成している。
export function selectImageFile(
inputTestId = "file",
fileName = "hello.png",
content = "hello"
) {
const user = userEvent.setup();
const filePath = [`C:\\fakepath\\${fileName}`];
const file = new File([content], fileName, { type: "image/png" });
const fileInput = screen.getByTestId(inputTestId);
const selectImage = () => user.upload(fileInput, file);
return { fileInput, filePath, selectImage };
}
後者は画像アップロードを行う uploadImage
関数をモックして、ダミーのレスポンスを返している。
import { ErrorStatus, HttpError } from "@/lib/error";
import * as UploadImage from "../fetcher";
import { uploadImageData } from "./fixture";
jest.mock("../fetcher");
export function mockUploadImage(status?: ErrorStatus) {
if (status && status > 299) {
return jest
.spyOn(UploadImage, "uploadImage")
.mockRejectedValueOnce(new HttpError(status).serialize());
}
return jest
.spyOn(UploadImage, "uploadImage")
.mockResolvedValueOnce(uploadImageData);
}