18
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

フロントエンド 自動テスト 始めました

Last updated at Posted at 2024-10-29

フロントエンドで自動テスト始めました

僕は自動テストのないPJで苦しんだ経験から、自分が担当するPJではテストを必ず書くようにしました。

というのも最近の数ヶ月ことなので、テストを書く上で気をつけていることをざっとメモっていこうと思います。

環境

フレームワーク

React 18系
NextJS 14系

テストライブラリ

JEST 29系
TestingLibrary 16系

静的解析

TypeScript 5系
ESLint 8系

どうしてテストがないときついか?

そもそもテストを書くモチベがないとテスト書かないですよね...?

僕が「テストが必要」だと感じている理由は主に3つあって

  • デグレの防止
  • リファクタリングを容易にする
  • テスト容易性なコードを書く力学が働くようになる

だと思っています。

デグレの防止

これはなんとなく想像がつくと思います。
開発につきものな「ライブラリの乗り換え」「機能追加」などのために、
既存コードを変更した時に、うっかり壊しちゃうことがあります。

テストがあるとこういった異常を検知しやすくなります。

リファクタリングを容易にする

ある意味、TDD(テスト駆動開発)と似たような考え方かもしれません。

「正しく動作していなければプロダクトとしての価値はない」という考えのもと

  • その価値を失わないように変更する
  • 一時的に価値が失われる瞬間があっても、その時間は最小限にとどめる

を意識しています。
「リファクタしたと思っていたけど、別の箇所で壊れていた」
という事態を避け、プロダクトの価値を保持したまま安心してリファクタリングを進めたいです

そのためにも既存コードが壊れていないことを担保し続けるテストが必要です。

テスト容易性なコードを書く力学が働くようになる

僕はこのモチベが一番大きいです。

テストを書くと、良い設計になっているか検証できます

悪い設計の特徴として、テスタビリティの低さ があると思っています。

「なんかテストしにくいなー」→「設計がどっか悪いんかな?」
と考えるようにしています。

  • 依存関係が多すぎて、テストしたいことと関係ないライブラリのモックが多いなー
  • 特定のfunctionだけテストしたいのに、別のfunctionが介在しているせいで、考慮しないといけないことが多いなー
  • たくさんprops渡さないとテストが実行できないなー

みたいに 「嫌な感じ」 がすると、テストを楽にできないか実装を見直すことが多いです。
(大体設計が悪いです)

テスティングライブラリ

単体テスト

個々の関数やコンポーネントが期待通り動作するかテストします。
私はJestを使用しています。

フロントエンドの単体テスト・結合テストではJest1強です。

ここ最近で、Jest互換であるVitestが登場しておりますが、
担当するPJの規模を鑑みてコミュニティが大きいJestを採用しています。

結合テスト

各コンポーネントや関数を組み合わせた時に、期待通りに動作するかをテストします。
私はJestReact Testing Library を使用しています。

E2Eテスト

こちらはまだ追加していません。

実際にどんな感じで書くの?

実行手順は基本的に3つ

  • コンポーネントをrenderingして
  • 要素を取得/なんらかの操作して
  • 結果を検証する

いわゆる AAAパターンというテスト手法を取り入れています。
こちらの記事 で勉強させていただきました。

これもテストの書き方は人それぞれなので、放置するといろんな書き方のバリエーションが生まれます。

書き方は統一した方が些細なことに悩まないで済みます。

テストは英語で書く

これ、意外と良い... と思いました。

  • 自然言語っぽく書ける
  • 書き方が統一される
  • テストのしにくさ・テストの複雑さに気づく時がある

自然言語っぽく書ける

ittest どっちでもいいのですが僕は 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.getElemetByIddocument.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 のテストはレンダリングテストの AAAAction が異なるので分けてテストします

  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" />)
  });

クエリチートシート

公式より拝借しています。
(あまりに使ったことがないものは非掲載です)

メソッドのサフィックスについて

TestingLibrarygetBy*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 ではユーザーの挙動をよりリアルに再現していることから、公式でもこっちを使うことが推奨されています。

userEventfireEventの違いについては、公式のドキュメントに明確な説明が記載されています。

非同期処理をテストする

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をつけ、waitForawaitを付与します。

そうしないと、非同期の処理が終わる前にアサーションが実行されて意図通りの結果が得られないことがあります

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/navigationuseSearchParams フックをラップしたものです。

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(),
}));

以下では、_useSearchParamsjest.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

社内でフロントエンドのテストを触ってもらう前に「これ読んどいて〜」と言いたいために書きました。

なので、いいテストの書き方や知見が見つかれば随時アップデートの予定です!

参考

Jest × Testing Libraryを用いた単体テストの考え方/使い方

Queries

fireEvent

Considerations for fireEvent

jest-dom

18
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?