フロントエンドで自動テスト始めました
僕は自動テストのないPJで苦しんだ経験から、自分が担当するPJではテストを必ず書くようにしました。
というのも最近の数ヶ月ことなので、テストを書く上で気をつけていることをざっとメモっていこうと思います。
環境
フレームワーク
React 18系
NextJS 14系
テストライブラリ
JEST 29系
TestingLibrary 16系
静的解析
TypeScript 5系
ESLint 8系
どうしてテストがないときついか?
そもそもテストを書くモチベがないとテスト書かないですよね...?
僕が「テストが必要」だと感じている理由は主に3つあって
- デグレの防止
- リファクタリングを容易にする
- テスト容易性なコードを書く力学が働くようになる
だと思っています。
デグレの防止
これはなんとなく想像がつくと思います。
開発につきものな「ライブラリの乗り換え」「機能追加」などのために、
既存コードを変更した時に、うっかり壊しちゃうことがあります。
テストがあるとこういった異常を検知しやすくなります。
リファクタリングを容易にする
ある意味、TDD(テスト駆動開発)と似たような考え方かもしれません。
「正しく動作していなければプロダクトとしての価値はない」という考えのもと
- その価値を失わないように変更する
- 一時的に価値が失われる瞬間があっても、その時間は最小限にとどめる
を意識しています。
「リファクタしたと思っていたけど、別の箇所で壊れていた」
という事態を避け、プロダクトの価値を保持したまま安心してリファクタリングを進めたいです。
そのためにも既存コードが壊れていないことを担保し続けるテストが必要です。
テスト容易性なコードを書く力学が働くようになる
僕はこのモチベが一番大きいです。
テストを書くと、良い設計になっているか検証できます 。
悪い設計の特徴として、テスタビリティの低さ があると思っています。
「なんかテストしにくいなー」→「設計がどっか悪いんかな?」
と考えるようにしています。
- 依存関係が多すぎて、テストしたいことと関係ないライブラリのモックが多いなー
- 特定の
function
だけテストしたいのに、別のfunction
が介在しているせいで、考慮しないといけないことが多いなー - たくさん
props
渡さないとテストが実行できないなー
みたいに 「嫌な感じ」 がすると、テストを楽にできないか実装を見直すことが多いです。
(大体設計が悪いです)
テスティングライブラリ
単体テスト
個々の関数やコンポーネントが期待通り動作するかテストします。
私はJestを使用しています。
フロントエンドの単体テスト・結合テストではJest
1強です。
ここ最近で、Jest
互換であるVitestが登場しておりますが、
担当するPJ
の規模を鑑みてコミュニティが大きいJest
を採用しています。
結合テスト
各コンポーネントや関数を組み合わせた時に、期待通りに動作するかをテストします。
私はJestとReact Testing Library を使用しています。
E2Eテスト
こちらはまだ追加していません。
実際にどんな感じで書くの?
実行手順は基本的に3つ
- コンポーネントを
rendering
して - 要素を取得/なんらかの操作して
- 結果を検証する
いわゆる AAA
パターンというテスト手法を取り入れています。
こちらの記事 で勉強させていただきました。
これもテストの書き方は人それぞれなので、放置するといろんな書き方のバリエーションが生まれます。
書き方は統一した方が些細なことに悩まないで済みます。
テストは英語で書く
これ、意外と良い... と思いました。
- 自然言語っぽく書ける
- 書き方が統一される
- テストのしにくさ・テストの複雑さに気づく時がある
自然言語っぽく書ける
it
か test
どっちでもいいのですが僕は it
にしています。
例えば、コンポーネントがレンダリングされていることをテストしたい時
it('renders as default', () => {
render(<Button />);
expect(screen.getByRole('button')).toBeVisible();
});
it
を主語にしてIt renders as default.
超シンプルだし、自然に読めるので個人的に好みです。
書き方が統一される
これもコード規約を作れば済む話なのですが、一応。
流暢に英語を書けない限り、書き方のバリエーションはある程度統一してきます。
日本語だとテストケースなど好き勝手に書けちゃうので...
さっきの例だと
- 「コンポーネントをレンダリングできることを確認」
- 「コンポーネントを描画できる」
- 「ボタンコンポーネントを表示できる」
とか、同じ意味でも結構違う文言で書かれてしまいます。
テストのしにくさ・テストの複雑さに気づく時がある
これは僕限定かもしれませんが、英語だとテストケースを書いていて
「ケースを表現しにくいな〜」と思うことがあります。
(テストにいっぱい準備が必要な時とか...)
例えば以下の日本語だと英語にするの結構めんどくさいと思うんですよね。
(副文いっぱいだし)
「売上」ボタンをクリックして「商品」ボタンにカーソルを当てると「売上」ボタンの選択状態が解除され、「商品」ボタンが選択状態になる
なので、シンプルなテストケースになるように
テスト自体をシンプルにできないかいつも考えています。
↓ これくらい簡単なケース名でないときついっす
メニューをクリックすると選択状態になる
=> It becomes selected when clicking the menu.
選択状態はホバーで移動する
=> It moves the selected state on hover.
シンプルに書く
コードだけでなく、テストもシンプルに短くしていきたいです。
変数を活用して、「似たようなテスト」はまとめる
例えば、「disabled なボタンはクリックイベントが発火しない」ことをテストしたい時...
it.each([
{ disabled: undefined, calledTimes: 1 },
{ disabled: false, calledTimes: 1 },
{ disabled: true, calledTimes: 0 },
])(
'calls onClick $calledTimes time when disabled is $disabled',
({ disabled, calledTimes }) => {
const onClick = jest.fn();
render(<Button disabled={disabled} onClick={onClick} />);
screen.getByRole('button').click();
expect(onClick).toHaveBeenCalledTimes(calledTimes);
},
);
これを実行すると以下のような結果になります。
✓ calls onClick 1 time when disabled is undefined (9 ms)
✓ calls onClick 1 time when disabled is false (6 ms)
✓ calls onClick 0 time when disabled is true (3 ms)
このようにit.each
を使用して、テストを短くまとめることができます。
参考: test.each(table)(name, fn, timeout)
そのほか%sなどを活用してユニークなテストが書けます。
it.each([undefined, []])('does not render when items is %s', (items) => {
const { container } = render(<ListItem items={items} />);
expect(container).toBeEmptyDOMElement();
});
こうした工夫を入れることでテストファイルがかなりスッキリします。
超基本的なデバッグ方法
TestingLibraryが便利な方法を用意してくれています。
いつも重宝するのは screen.debug() で、
レンダリングされたDOM要素を表示して特定の要素が意図通りに表示されるか確認できます。
ユーザーの操作に沿ったテストにすること
TestingLibrary
を例にします。
やりがちなテスト方法
document.getElemetById
、 document.querySelector
を使用して
クラス名やIDを使用して要素を取得する。
it('renders circle class when variant props is circle', () => {
const { container } = render(<Image variant="circle" />);
expect(container.querySelector('img')).toHaveClass('circle');
});
これでもテストは通るんですけど人間が実際のアプリケーションを操作する時に、IDやクラス名を確認しながらテストを書かないですよね?
Priorityを意識してテストを書く
Priorityにある通り、Role
属性・ラベル・テキスト・入力値のようなマシーン/ヒューマンリーダブルな要素を取得してテストを実行します。
it('renders circle class when variant props is circle', () => {
render(<Image alt="testImage" variant="circle" />);
// 余談: img role は alt がないと取得できない
expect(screen.getByRole('img')).toHaveClass('circle');
});
できる限り、role
属性を指定して要素を取得する方がベターです。
アクセシビリティロールが実装段階で設置できているかの確認にもなります。
どのようなRoleがあるかはWAI-ARIA 1.3 日本語訳が容易されているのでそちらを参考にしたいです。
テストしておきたい項目
基本的にロジックはutils
, hooks
に分離し、
uiコンポーネント
の責務はできるだけ、レンダリングのみに集中したいです。
その上で、コンポーネントのテストでは以下の項目をテストします。
- コンポーネントがレンダリングされる
-
prop
が渡されている- レンダリングされているならその存在チェック
-
function
なら呼び出されていることをチェック
実際に簡単なボタンコンポーネントで例を挙げてみます。
ボタンコンポーネント
import React, { ReactNode } from 'react';
type ButtonProps = {
type?: 'button' | 'submit';
onClick?: () => void;
disabled?: boolean;
className?: string;
children?: ReactNode;
};
export const Button = ({
type = 'button',
onClick,
disabled = false,
className = '',
children,
}: ButtonProps) => {
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={className}
>
{children}
</button>
);
};
「PropsにはButtonHTMLAttributes<HTMLButtonElement>
を使おうよ...」
とかツッコミあるかもしれませんが、できるだけ簡単なボタンを意識して書きました。
コンポーネントがレンダリングされる
it('renders as default', () => {
render(<Button />);
expect(screen.getByRole('button')).toBeVisible();
});
単純にコンポーネントが描画されることをチェックします。
tipsとして、要素があるかどうかを確認する時は、マッチャーにtoBeInTheDocument
ではなくtoBeVisibleを使うことです。
toBeVisible
は、以下のように単に描画されるだけでなく、ヒューマン/マシーンが視認できるかどうかも確認してくれます。
it is present in the document
it does not have its css property display set to none
it does not have its css property visibility set to either hidden or collapse
...
prop
が渡されている
propとして渡した要素がレンダリングされることを確認します。
it('renders children', () => {
render(<Button>ボタン</Button>);
expect(screen.getByText('ボタン')).toBeVisible();
});
実際は正常ケースのprops
を用意して、まとめてアサートすることが多いです。
ただ、onClick
のテストはレンダリングテストの AAA
の Action
が異なるので分けてテストします
const props = {
children: 'ボタン',
type: 'submit',
disabled: true,
className: 'text-red',
onClick: jest.fn(),
} as ButtonProps;
it('renders props', () => {
render(<Button {...props} />);
expect(screen.getByText('ボタン')).toBeVisible();
expect(screen.getByRole('button')).toHaveClass('text-red');
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
expect(screen.getByRole('button')).toBeDisabled();
});
こうしておくとテストの時にどんなプロパティについてテストするのかが見やすいです。
↓だとclassName
のテストをしたいことが一目瞭然です。
it('applies custom className', () => {
render(<Button {...props} className="custom-class" />)
});
クエリチートシート
公式より拝借しています。
(あまりに使ったことがないものは非掲載です)
メソッドのサフィックスについて
TestingLibrary
のgetBy*
、queryBy*
、およびfindBy*
メソッドには、異なる用途とシナリオがあります。
-
getBy*
: 指定した条件に一致する要素が必ず存在する場合に使用 -
getAllBy*
: 指定した条件に一致する複数の要素を取得する時に使用 -
queryBy*
: 条件に一致する要素が存在しない可能性がある場合に使用 -
queryAllBy*
: 条件に一致する複数の要素を取得しますが、見つからなければ空配列を返却する -
findBy*
: 非同期で要素を取得する場合に使用 -
findAllBy*
: 非同期で条件に一致する複数の要素を取得する時に使用
ByRole
ariaロール
を持つ要素を検索
アクセシビリティ要件に準拠したテストを行う際に特に有用
まずこのクエリを使うことを検討すべし
<button role="button">Click me</button>
// getByRole("button")でこのbuttonを取得可能
ByText
要素の中のテキスト内容(特に静的なテキスト)が特定の文字列と一致する要素を検索
<button>Submit</button>
// getByText("Submit")でこのbuttonを取得可能
ByLabelText
<label>タグ
と関連付けられた要素、またはaria-label属性
を持つ要素を検索
<label for="username">Username</label><input id="username" />
// メソッド: getByLabelText("Username")でこのinputを取得可能
ByPlaceholderText
placeholder属性
を持つ<input>
や<textarea>
などの入力要素を検索
<input placeholder="Enter username" />
// getByPlaceholderText("Enter username")でこのinputを取得可能
ByAltText
画像やメディア要素のalt属性
を使用して検索
<img alt="Profile picture" src="profile.jpg" />
// getByAltText("Profile picture")でこのimgを取得可能
ByDisplayValue
<input>
や<textarea>
の現在の値で要素を検索
選択リストの選択中の値にも適用可能
<input value="test value" />
// getByDisplayValue("test value")でこのinputを取得可能
ByTitle
任意の要素のtitle属性
、またはSVG要素内の<title>タグ
で検索
<div title="tooltip">Hover here</div>
// getByTitle("tooltip")でこのdivを取得可能
ByTestId
テスト専用のカスタム属性(data-testid
: 開発者がテスト用に付与する属性)で要素を検索
<div data-testid="custom-element">Test Element</div>
// getByTestId("custom-element")でこのdivを取得可能
JestDom チートシート
公式の内容を拝借しています。
かなり多いので個人的に使いそうなものにしています。
toBeDisabled / toBeEnabled
要素が無効か有効かをチェック
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByRole('textbox')).toBeEnabled();
toBeEmptyDOMElement
要素が空であるか(子要素やテキストがないか)をチェック
// propsがないと何も描画しないコンポーネント
const { container } = render(<Badge />);
expect(container).toBeEmptyDOMElement();
toBeInTheDocument
要素がDOMに存在するかどうかをチェック
基本的にtoBeVisible
を優先する
expect(screen.getByRole('button')).toBeInTheDocument();
toBeVisible
要素が画面に見えているかをチェック
expect(screen.getByText('Loading...')).toBeVisible();
toHaveClass
要素に特定のCSSクラスが適用されているかをチェック
expect(screen.getByTestId('alert')).toHaveClass('warning');
toHaveAttribute
要素が特定の属性を持っているかをチェック
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com');
toHaveFocus
要素にフォーカスがあるかをチェック
expect(screen.getByLabelText('Username')).toHaveFocus();
toBeInvalid / toBeValid
フォームの入力フィールドが無効か有効かをチェック
バリデーションのテストで使用
expect(screen.getByLabelText('Email')).toBeInvalid();
expect(screen.getByLabelText('Email')).toBeValid();
toBeRequired
入力要素が必須(required属性)かどうかをチェック
expect(screen.getByLabelText('Name')).toBeRequired();
toHaveFormValues
フォームが特定の値を持っているかを一括でチェック
expect(screen.getByRole('form')).toHaveFormValues({ username: 'JohnDoe', email: 'john@example.com' });
toHaveStyle
要素に特定のインラインスタイルが適用されているかをチェック
expect(screen.getByTestId('container')).toHaveStyle('display: flex');
toHaveTextContent
要素に特定のテキストが含まれているかをチェック
expect(screen.getByTestId('message')).toHaveTextContent('Hello, World!');
toBeChecked / toBePartiallyChecked
チェックボックスやラジオボタンが選択されているかをチェック
toBePartiallyChecked
は一部選択された状態がある場合で使用する
expect(screen.getByLabelText('Agree to terms')).toBeChecked();
Jest マッチャー チートシート
リファレンスから拝借しています
これはかなり多いので使っているものだけにします
値が同等かどうかをテストする
toBe(value)
値が特定のプリミティブ型に一致するかをチェック
expect(result).toBe(10);
toEqual(value)
値が同等であるかをチェック
オブジェクトや配列の内容を比較する
expect(obj).toEqual({ key: 'value' });
toStrictEqual
オブジェクトが同じ構造と型を持つかどうかをチェック
クラスや、配列内の未定義(undefined)なプロパティを含む、厳密なオブジェクトの比較に使用する
(大体 toEqualで十分なケースが多い気がする)
toMatch(regexp | string)
文字列が正規表現や特定の文字列と一致するかをチェック
expect('Hello World').toMatch(/World/);
toBeNull()
値がnullであるかをチェック
expect(value).toBeNull();
toBeTruthy() / toBeFalsy()
値がtrue/falseであるかを確認する際に使用します。
expect(value).toBeTruthy();
toBeUndefined() / toBeDefined()
undefined であることをチェック
単にundefinedではないことを確認するなら、toBeDefined でアサーションするといい
モック関数の呼び出しをテストする
toHaveBeenCalled()
モック関数が呼び出されたことをチェック
expect(mockFn).toHaveBeenCalled();
toHaveBeenCalledTimes(number)
モック関数が指定回数だけ呼ばれたかをチェック
expect(mockFn).toHaveBeenCalledTimes(2);
toHaveBeenCalledWith(arg1, arg2, ...)
モック関数が指定した引数で呼ばれたかをチェック
expect(mockFn).toHaveBeenCalledWith('value1', 'value2');
配列・オブジェクトに関するマッチャー
toHaveLength(number)
配列や文字列が特定の長さを持つかをチェック
expect(arr).toHaveLength(3);
toContain(item)
配列や文字列が特定のアイテムを含んでいるかをチェック
主に、プリミティブ値や参照の一致に使用する
expect(arr).toContain('item');
toContainEqual(item)
配列が特定の値と同等のアイテムを含んでいるかをチェック
toContain
と違って、オブジェクトやネストした配列の一致に使用する
オブジェクトの比較
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
expect(users).toContainEqual({ id: 1, name: 'Alice' });
配列の比較
const matrix = [
[1, 2],
[3, 4],
[5, 6],
];
expect(matrix).toContainEqual([3, 4]);
数値の大小を比較する
toBeGreaterThan(number) / toBeLessThan(number)
値が指定の値より大きいか/小さいかをチェック
expect(result).toBeGreaterThan(10);
expect(result).toBeLessThan(10);
toBeGreaterThanOrEqual(number) / toBeLessThanOrEqual(number)
値が指定の値以上であるかをチェック
expect(result).toBeGreaterThanOrEqual(10);
expect(result).toBeLessThanOrEqual(10);
イベントを実行する
「クリックするとfunctionが呼び出される〜」というようにテスト中でイベントを発火させたい時は、fireEventを使用します。
// ボタンをクリックする
fireEvent.click(screen.getByRole('button'))
DOMイベントを発火させるにはuserEventもあります。
fireEvent dispatches DOM events, whereas user-event simulates full interactions, which may fire multiple events and do additional checks along the way.
公式の見解 的には、userEvent
ではユーザーの挙動をよりリアルに再現していることから、公式でもこっちを使うことが推奨されています。
userEvent
とfireEvent
の違いについては、公式のドキュメントに明確な説明が記載されています。
非同期処理をテストする
waitFor
何らかの時間を待つ必要がある場合は、waitForを使用して、期待が過ぎるのを待つことができます。
↓では非同期で実行されているフォームのサブミット処理をテストしています。
it('calls onSubmit when clicking submit button', async () => {
render(<Form />);
fireEvent.click(screen.getByRole('button', { name: 'form.key.submit' }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(calledWith));
});
非同期処理を実行するので、テスト関数にasync
をつけ、waitFor
にawait
を付与します。
そうしないと、非同期の処理が終わる前にアサーションが実行されて意図通りの結果が得られないことがあります
findBy*
のメソッドは 内部的にwaitFor を実行している
単に非同期処理を待って要素を取得するのであればfindBy
でも同じような書き方ができます。
公式ドキュメントでも記載があります。
ここは好みの問題になると思います。
時間を操作する
今の時間を特定の時間に固定したい時はuseFakeTimersを使います
任意の時間を経過したことにする
jest.useFakeTimers();
jest.advanceTimersByTime(number);
現在時刻をコントロールする
偽のタイマーで使用される現在システム時刻を変更するにはsetSystemTimeを使用します。
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date('2024-10-21T09:00:00.00+09:00'));
});
afterEach(() => {
jest.useRealTimers();
});
jest.useFakeTimers()
を呼び出すと、ファイル内のすべてのテストで偽のタイマーが使用されるので、jest.useRealTimers() で元のタイマーを復元させる必要があります。
関数をモックする
コンポーネント内で関数を実行している場合、その関数の返り値を固定したい時があります。
そんな時はjest.mockを使用します。
(既存のモジュールの全てを上書きしてモック化したい時に使用するイメージです)
例えば以下のようにbutton
コンポーネント内でgetButtonText
というutil
を実行しているとする。
import getButtonText from '@/utils/button.util';
export const Button = (props: ButtonProps) => {
const buttonText = getButtonText(props.text)
...
テストではこういった関数の返り値を固定したい時があります。
以下のように引数をそのまま返却するようにコントロールできます。
jest.mock('@/utils/button.util', () => ({
getButtonText: (data: string) => data,
}));
関数の振る舞いをモックする
コールバック関数のテストや特定の関数がどのように呼び出されるべきかをテストするときにjest.fn()が便利です。
jest.fn()
のmockFn.mockReturnValue(value)を使用します。
例えば以下のようなhookのテストを書くことを考えてみる
このカスタムフック useSearchParams
は、next/navigation
の useSearchParams
フックをラップしたものです。
import { useSearchParams as useNextSearchParams } from 'next/navigation';
export function useSearchParams(param: string) {
const searchParams = useNextSearchParams();
const urlParam = parseInt(`${searchParams.get(param)}`);
return { [param]: isNaN(urlParam) ? undefined : urlParam };
}
ではテストをどう書いていくか...
まずはuseNextSearchParams()
部分をモック関数に置き換えてみる。
import { useSearchParams as _useSearchParams } from 'next/navigation';
jest.mock('next/navigation', () => ({
useSearchParams: jest.fn(),
}));
const useNextSearchParams = _useSearchParams as jest.Mock;
以下では next/navigation モジュール
をモックし、そのモジュール内の useSearchParams
関数を jest.fn(
) に置き換えている。
jest.mock('next/navigation', () => ({
useSearchParams: jest.fn(),
}));
以下では、_useSearchParams
を jest.Mock 型
にキャストすることで、モック関数として扱えるようにする。
(これにより、useNextSearchParams
関数が呼び出された際の引数や結果を保持できるようになる)
ここでuseSearchParams
が返す値をmockReturnValue(...)
などを用いて、任意の検索パラメータを返すように設定できる。
const useNextSearchParams = _useSearchParams as jest.Mock;
実行部分は以下のようになる。
mockReturnValue
を使用することで URLSearchParamsオブジェクトを返却している。
it('returns param infomation from search params', () => {
useNextSearchParams.mockReturnValue(new URLSearchParams('?postId=1'));
const { result } = renderHook(() => useSearchParams('postId'));
expect(result.current).toStrictEqual({ postId: 1 });
});
また、モックオブジェクトはモックしたfunction
が呼び出された際の引数や結果を保持しているので、以下のようにどんな引数で、何回実行されたかをアサーションすることもできる。
expect(mockFn).toHaveBeenCalledTimes(2);
手動テストは必要か?
コンポーネントテストを充実させても、手動テストは必要です
どうしても細かいデザインのテストを網羅したテストを書こうとするとキリが無いので、僕は「レンダリング」「Props」のテストのみと決めています。
そのため、他のコンポーネントと組み合わさってできたデザインと細かいデザインの確認はstorybook
を使用して確認しています。
最後に
ここまで見て頂きありがとうございます。m(._.)m
社内でフロントエンドのテストを触ってもらう前に「これ読んどいて〜」と言いたいために書きました。
なので、いいテストの書き方や知見が見つかれば随時アップデートの予定です!