iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

フロントエンドのテストを書きたい!

はじめに


こんにちは、株式会社iimonでエンジニアをしている遠藤です!
本記事はiimonアドベントカレンダー5日目の記事となります。

最近、フロントエンドのコードでリファクタリングをしたい箇所があったのですが、該当箇所のテストコードがありませんでした。

また、自分自身「フロントエンドのテストコードって何をどうやって書けばいいんだ・・・?」という状態だったので、今回はテストの種類と戦略について学んだことを整理しつつ、実際にReactのテストで初心者の自分が躓いたポイントを紹介します。(誰かのお役に立てたら幸いです)

なお、本記事に登場するコード例では、 テスティングフレームワークにVitestを使用しています。
また、テストは自動テストを指します。

テストの種類


どういったテストを書けばいいかを理解するためには、どの範囲をカバーするかによって分類される「テストレベル」と、どういった目的のテストであるかによって分類される「テストタイプ」を理解することが大切です。
その上で、「テストレベル」と「テストタイプ」を組み合わせて、適切なテストを選択します。

テストレベル

大きく分けると3つに分類されます。

分類 説明
単体テスト 「モジュール単体」が提供する機能に着目したテスト。対象モジュールが、定められた入力値から期待する出力値を返すことを検証する。
結合テスト 「複数のモジュールが連動する機能」に着目したテスト
E2Eテスト システム全体を通し、ヘッドレスブラウザ+UIオートメーションで実施するアプリケーションの稼働状況に忠実なテスト

これらの分類は、以下の図のように表現できます。

上層にいけばいくほど、実際のアプリケーションの動作に忠実なテストとなりますが、その分実行時間やメンテナンス・実装面でコストがかかります。
逆に、下層に行けば行くほど、テストする範囲が狭くなるので、忠実性は低くなりますが、その分実行時間やメンテナンス・実装面でのコストは低いです。

テストタイプ

テストタイプは検証目的によって選択します。
代表的なテストタイプとして以下を取り上げます。

分類 説明
機能テスト 開発対象の機能に不具合がないかを検証するテスト。フロントエンドでは、開発の対象領域の中心がユーザーが操作するUIコンポーネントであるため、主にUIコンポーネントを操作する「インタラクションテスト」が該当する。
非機能テスト 機能要件以外の要件に対するテスト。心身特性に隔たりのない製品であることを検証する「アクセシビリティテスト」など。
リグレッションテスト 特定の時点から前後の差分を検出して想定外の不具合が発生しないかを検証するテスト。フロントエンドでは、主に見た目のリグレッションが発生していなことを確認するための「ビジュアルリグレッションテスト」が該当する。

テスト戦略

テストレベルのところで述べたように、忠実性とコストは相反関係にあります。
ここでは、どのテストレベルのテストをどれくらい書くかを判断する上で参考となるテストモデルについて見ていきます。

  1. テストピラミッド型

    Mike Cohn氏の『Succeeding with Agile』で紹介されたテスト戦略モデルで、下層のテストを充実させることに重きを置いた戦略です。
    ただ、上層のテストがなくていいというわけではなく、プログラム同士や外部サービスとの連携などの検証が必要な箇所ではE2Eテストや統合テストを書きます。 gihyo.jp

  2. テスティングトロフィー型

    https://x.com/kentcdodds/status/960723172591992832 「Testing Library」の開発者であるKent C. Dodds.氏が提唱するテスト戦略のモデルで、統合テストを充実させることに重きを置いた戦略です。
    Webフロントエンドでは、UIコンポーネント単体で提供される機能は少ないため、それらが適切に連携していることを統合テストで検証する必要があります。
    そして、そういった統合テストを書くことで、単体でテストする必要がなくなることが多いという考え方のようです。 kentcdodds.com

  3. アイスクリームコーン型(アンチパターン

    一般的にアンチパターンとされるテスト戦略です。
    E2Eテストで大半のテストをカバーしようとすると、テストの作成/実行にも多くのコストがかかります。
    また、コード以外の要因でテストが失敗するなど不安定な動作が増え、さらに広範囲なテストであるためテストが失敗した箇所の特定にも時間ががかかります。
    これらの要因により、普段の開発体験の低下に繋がりかねないため、アンチパターンとされているようです。 logmi.jp

Reactのテストで個人的に躓いたところ

自分がリファクタしたかった内容としては、
1. 大きすぎるカスタムフックの分割
2. コンポーネント内でカスタムフックから取得しているonClickイベントに対する処理を、propsで受け渡す
でした。

どちらもインタラクションテストを書いて操作時の挙動が変わってなければ良いかなと思ったので、UIコンポーネントの統合テストを書きました。
また1に関しては、複雑なロジックのテストは欲しいので、カスタムフックのロジックの単体テストも書くことにしました。

ここからは、それぞれのテストで躓いた点をご紹介します。
(実際のテストを簡略化したテストコードを例として示しています。)

UIコンポーネントのテスト

ここでは、
Testing Library(UIコンポーネントのテスト用ライブラリ)
@testing-library/jest-dom/vitest(DOMのテストに特化したカスタムマッチャー)
@testing-library/user-event(リアルなユーザー操作をするためのライブラリ)
を使用します。

  1. テスト対象のコンポーネントレンダリング
  2. レンダリングした内容から特定のDOM要素を取得
  3. DOMに対して操作を与える
  4. 検証

ざっくりこんな感じの流れでテストが書けそうなので、早速書いてみました!

えい。

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

const user = userEvent.setup();

test('チェックボックスをクリックするとチェックがつく', () => {
  render(
    <Hoge />
  );

  const targetCheckbox = screen.getByRole('checkbox');
  user.click(targetCheckbox);
  expect(targetCheckbox).toBeChecked();
});

あれれ・・・?
失敗しています😢

Reduxを使っている場合

エラー内容

  Error: could not find react-redux context value; please ensure the component is wrapped in a <Provider>

  > Module.useHoge [as default] src/hooks/useHoge.ts:8:20
        6| 
        7| const useHoge = () => {
        8|   const dispatch = useAppDispatch();
         |                    ^
        9| 

テスト対象のHogeコンポーネント内で呼び出しているカスタムフックでreduxを使用していたためです。
一瞬、「モックしたらいいのか?」などと思いましたが、エラー文にまんま答えが書いてありました。

コンポーネントがProviderでラップされていることを確認してください。

言われた通り、コンポーネントProviderでラップしてあげます。

解決策

import { store } from '@/redux/store';
import { Provider } from 'react-redux';

test('チェックボックスをクリックするとチェックがつく', () => {
  render(
    <Provider store={store}>
      <Hoge />
    </Provider>
  );

  const targetCheckbox = screen.getByRole('checkbox');
  user.click(targetCheckbox);
  expect(targetCheckbox).toBeChecked();
});

ちなみにモックに関しては、Reduxのドキュメントにこのように書いてありました。

セレクター関数や React-Redux フックをモック化しようとしないでください。ライブラリからのインポートをモック化することは脆弱であり、実際のアプリ コードが動作していることを確信できません。

統合テストのテスト精度とかの話になるのかなと解釈しています。
今回は統合テストだったので、上記のように解決しています。
単体テストやReduxの値の変化がテスト内容に関係ないならモックでもいいかもしれません。
詳しくはドキュメントを参照してみてください。
redux.js.org

@mui/x-date-pickerを使用している場合

エラー内容

Error: MUI: Can not find the date and time pickers localization context.
It looks like you forgot to wrap your component in LocalizationProvider.
This can also happen if you are bundling multiple versions of the `@mui/x-date-pickers` package

エラー文を読むに、
1. コンポーネントLocalizationProviderでラップするのを忘れている
2. 複数のバージョンの @mui/x-date-pickers パッケージをバンドルしている
のいずれかの場合に起こるエラーのようです。

今回の場合は、1が原因だったので、ご指摘の通りコンポーネントLocalizationProviderでラップしてあげます。
propsに渡すdateAdapteradapterLocaleはアプリケーションの設定と合わせます。

解決策

import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import 'dayjs/locale/ja';

test('チェックボックスをクリックするとチェックがつく', () => {
  render(
    <Provider store={store}>
      <LocalizationProvider
        dateAdapter={AdapterDayjs}
        adapterLocale="ja">
        <Hoge />
      </LocalizationProvider>
    </Provider>
  );

  const targetCheckbox = screen.getByRole('checkbox');
  user.click(targetCheckbox);
  expect(targetCheckbox).toBeChecked();
});
MUIのコンポーネントでテーマを使用している場合

エラー内容

TypeError: Cannot read properties of undefined (reading 'dark')

darkモードのスタイルを参照できずにエラーになっています。
MUIのコンポーネントでテーマを使用する際には、コンポーネントThemeProviderでラップしてあげる必要がありました。

解決策

import { ThemeProvider, createTheme } from '@mui/material/styles';

// カスタムテーマを使用している場合は、そのテーマをimportしてThemeProvideのthemeに渡します。
const theme = createTheme();

test('チェックボックスをクリックするとチェックがつく', () => {
  render(
    <Provider store={store}>
      <LocalizationProvider
        dateAdapter={AdapterDayjs}
        adapterLocale="ja">
        <ThemeProvider theme={theme}>
          <Hoge />
       </ThemeProvider>
      </LocalizationProvider>
    </Provider>
  );

  const targetCheckbox = screen.getByRole('checkbox');
  user.click(targetCheckbox);
  expect(targetCheckbox).toBeChecked();
});

ここまで読んでくださった方すみません。
薄々感じている方もいらっしゃるかもしれません。

「これ、別にテスト関係ないじゃん」って。

はい。気づいたら全て「コンポーネントをラップしましょう」というテストに関わらずなエラー集になってしまいました、、、😭

これを解決したら簡単にコンポーネントテストが実装できてしまうという、ライブラリの素晴らしさが分かりましたね(苦し紛れ)
テストで対象コンポーネントレンダリングする時点でエラーが起きる場合は、アプリケーションのルートコンポーネントなどReactツリーをラップしていそうな箇所を確認しましょう(当たり前ですね)
自分はテストの書き方ことだけ考えていて完全に忘れていました。

コンポーネントテストの度にラップするのは面倒なので、自分は以下のようにcustomRenderを作成して使うようにしました。

customRender.tsx

import 'dayjs/locale/ja';
import { ThemeProvider } from '@mui/material/styles';
import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { Provider } from 'react-redux';
import { store } from '@/redux/store';
import { theme } from '@/constants/theme';
import { render } from '@testing-library/react';

const customRender = (component: React.ReactElement) => {
  render(
    <Provider store={store}>
      <LocalizationProvider
        dateAdapter={AdapterDayjs}
        adapterLocale="ja">
        <ThemeProvider theme={theme}>
          {component}
        </ThemeProvider>
      </LocalizationProvider>
    </Provider>
  );
};

export default customRender;

Hoge.test.tsx

import customRender from 'testUtils/customRender';

test('チェックボックスをクリックするとチェックがつく', () => {
  customRender(<Hoge/>);

  const targetCheckbox = screen.getByRole('checkbox');
  user.click(targetCheckbox);
  expect(targetCheckbox).toBeChecked();
});
カスタムフックのテスト

最後にお詫びにちゃんとテストの話をします。

躓きというよりは、「カスタムフックのテストってどうやって書くの・・・?」という疑問がまず最初に沸いたので、その点について書き留めておきます。

カスタムフックはコンポーネントもしくは他のカスタムフックからのみ呼び出せます。
ということはテスト内でどうやってカスタムフックを呼び出すんだ🤔と思って調べたところ、
@testing-library/reactのrenderHookactを使用することで、簡単にカスタムフックのロジックのテストが書けるみたいです。

例えば、以下のようなカスタムフックがあるとします。

import { useState } from 'react';

const useHoge = () => {
  const [companyName, setCompanyName] = useState<string>('');

  const handleSave = (value: string) => {
    setCompanyName(value);
  };

  return {
    companyName,
    handleSave,
  };
};

export default useHoge;

以下のようにテストをかけます。

import { renderHook, act } from '@testing-library/react';
import useHoge from '@/hooks/useHoge';

describe('会社名の状態テスト', () => {
  test('初期値が空文字であること', () => {
    const { result } = renderHook(() => useHoge());
    expect(result.current.companyName).toBe('');
  });

  test('handleSaveで値が更新されること', () => {
    const { result } = renderHook(() => useHoge());
    act(() => {
      result.current.handleSave('iimon');
    });
    expect(result.current.companyName).toBe('iimon');
  });
});

renderHookを使用すると、テスト用のReactコンポーネント内でフックをレンダリングすることができます。
フックの返り値はresult.currentでアクセスできます。
そしてactを使用して、状態更新や副作用の処理を待機してから検証します。

便利・・・!

まとめ


テストレベルやテスト戦略、またテストタイプの大まかな概念はWebフロントエンドのテストに限らず、ソフトウェア開発に通ずるものだと思います。
その上で、Webフロントエンドでは特にどういったテストタイプのテストが効果的なのかを知ることで、Webフロントエンドのテストについて考えやすくなりました。
Webフロントエンドのテストに限らず、適切にテストレベルとテストタイプを選択してテストコードを書けるようになっていきたいと思いました。

自分と同じように「フロントエンドのテスト書きたいけどどうやって書いていいかわからない」という方のお役に少しでも立てれば幸いです。
記事の内容に誤り、アドバイス等ございましたらご指摘いただけますと幸いです!

最後まで読んでくださりありがとうございます!

また、弊社ではエンジニアを募集しております。
ご興味がありましたらカジュアル面談も可能ですので、下記リンクより是非ご応募ください!
iimon採用サイト / Wantedly / Green

明日は分割マスターのほでぃさんの記事です!
どんな記事を書いてくださるか楽しみです!

参考記事

吉井健文著(2023)『フロントエンド開発のためのテスト入門 今からでも知っておきたい自動テスト戦略の必須知識』翔泳社. www.shoeisha.co.jp

zenn.dev

logmi.jp

gihyo.jp

kentcdodds.com

qiita.com