見出し画像

Jest と React Testing Library を用いた Integration Test

CyberZのWebフロントチームでエンジニアをしている山﨑です。CyberZでは、新たな技術の導入を積極的に行なっており、その中で Jest と React Testing Library を使用した Integration Test の実装を行なったため、その実装例をご紹介したいと思います。

使用技術
  React, Next.js, Jest, React Testing Library

結合テスト(Integration Test)とは

まず、簡単に結合テスト(Integration Test)の説明をしたいと思います。React Testing Library の著者の方は、結合テストとは「複数のユニットが調和して動作することを検証する」と以下の記事で述べられています。
Static vs Unit vs Integration vs E2E Testing for Frontend Apps
抽象的でわかりにくさがあるため、もう少し具体的かつ簡潔にいうと「コンポーネントが機能としてうまく動作するかを確認するためのテスト」です。各コンポーネントや関数を組み合わせてアプリとして表示した時に、ユーザーが問題なく機能を使えるかという部分に対してテストを行うものになります。この Intergration Test を導入することで、コンポーネントのリファクタリング(機能ではなく実装の変更)に対して、機能の安全性を長期に渡って担保してくれるものになります。

React Testing Library の説明

React Testing Library は「React のコンポーネントを操作する API を使用するためのライブラリ」です。基本的なテストの場合、以下の4つの API を使用することで実装を行うことができます。

  • render

このメソッドは、テストを行いたいコンポーネントを描画させるためのものになります。この render 実行後からテストを行うコンポーネントのそれぞれの要素に対してアクセスすることができます。そのため、基本的にはテストの序盤に実行し、テストに応じてコンポーネントを操作することになります。

  • screen

このメソッドは、描画されたコンポーネントから値などの要素を検索・取得するためのものになります。取得方法にもいくつかの種類があり、検索した要素が見つからなかった時の挙動や、描画されているどの情報から取得を行うのかなど、複数のパターンから都度適切な取得方法を選択し、テストとして検証を行なっていきます。

  • fireEvent & userEvent

これらのメソッドは、ユーザーが実際に行うイベントを実行できるものになります。具体的には、ユーザーの値の入力やボタンの押下など、ユーザーが機能を使用するために必要な行動をメソッドとして実行することができます。 fireEvent と userEvent の違いは、共にユーザーのイベントを実行できるものになりますが、 userEvent の方がよりブラウザに近い挙動をするため、userEvent を優先して使用することが公式から推奨されています。

Most projects have a few use cases for fireEvent, but the majority of the time you should probably use @testing-library/user-event.

しかし、userEvent が fireEvent の全ての機能を含んでいるわけではないので、都度 userEvent で使用できるか確認する必要があります。

  • waitFor

このメソッドは、非同期の処理を待つ必要があるテストを実行してくれるものになります。また、コンポーネントの再描画の際にも使用するため、検証が失敗しているかテストする場合などでも実行するメソッドになります。

Integration Test の実装

例として、以下のような簡単なフォームを参考にして、テストの実装を行なっていきます。
このようなフォームの場合、Integration Test の成功を表す「コンポーネントが機能として動作している状態」は以下のようになります。

  1. メールアドレスが入力される

  2. パスワードが入力される

  3. 送信ボタンがクリックされる

  4. TOPページへ遷移がされる

// view.tsx

import { useRouter } from 'next/router';
import { ChangeEvent, useCallback, useState } from 'react';

const IntegrationTest: NextPage = () => {
  const router = useRouter();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const onEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value);
  };

  const onPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
    setPassword(e.target.value);
  };

  const onSubmit = useCallback((e: ChangeEvent<HTMLFormElement>) => {
    e.preventDefault();

    if (email !== '' && password !== '') {
      // ここで新規登録の処理
      router.push('/');
    }
  }, [email, password, router]);

  return (
    <form onSubmit={ onSubmit }>
	  <div>
	    <label htmlFor='email'>メールアドレス</label>
        <input
	        id='email'
          type='email'
          value={ email }
          onChange={ onEmailChange }
        />
      </div>
      <div>
		<label htmlFor='password'>パスワード</label>
        <input
          id='password'
          type='password'
          value={ password }
          onChange={ onPasswordChange }
        />
      </div>
      <button placeholder='新規登録'>新規登録</button>
    </form>
  );
};

export default IntegrationTest;

そして、それを Integration Test として実装すると、以下のようになります。

// view.test.tsx

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

import { IntegrationTest } from './view';

// 後で詳細説明
const push = jest.fn();
jest.mock('next/router', () => {
  const router = jest.requireActual('next/router');
  return {
    ...router,
    useRouter: () => {
      return {
        push,
      };
    },
  };
});

describe('Signup', () => {
  test('新規登録成功', async () => {
    // 検証したいフォームのコンポーネントを描画
    render(<IntegrationTest />);

	// 新規登録に必要な各要素の取得
	const email = screen.getByLabelText('メールアドレス') as HTMLInputElement;
    const password = screen.getByLabelText('パスワード') as HTMLInputElement;
    const button = screen.getByRole('button') as HTMLButtonElement;

	// フォームの登録に必要な各要素に値を追加
    fireEvent.change(email, { target: { value: 'yamazaki@test.co.jp' } });
    fireEvent.change(password, { target: { value: 'Test1234' } });

	// 値が追加されているかテスト
    expect(email.value).toBe('yamazaki@test.co.jp');
    expect(password.value).toBe('Test1234');

	// フォームの登録のボタンを押下する動作
    userEvent.click(button);

	// ボタン押下時の router.push が動作しているかテスト
    await waitFor(() => {
      expect(push).toBeCalledTimes(1);
    });
  });
});

また、各要素の取得方法について React Testing Library 公式から、どの方法で要素を取得すべきかという優先順位も明示されており、基本的にはその優先度に従って取得するのが良いでしょう。
About Queries | Testing Library

Next Router のモック化

フォームの登録を行なった際に、Next.js の useRouter を使用して、TOPページに遷移する処理を実装しています。その「TOPページへの遷移」という部分をテストする上で必要な処理が以下になっております。

const push = jest.fn();
jest.mock('next/router', () => {
  const router = jest.requireActual('next/router');
  return {
    ...router,
    useRouter: () => {
      return {
        push,
      };
    },
  };
});

もしこの処理を追加せずにテストを実行するとエラーが吐かれてしまいます。

error message

エラーが吐かれてしまう背景としては、useRouter などの Hooks の処理は、クライアント側でのみ実行される処理になっています。そのため、サーバー側でのみ実行されるテストの際などでは、使用することができないものになっています。
この問題を解決するために、 useRouter をモック(別の機能に置き換える)をしています。具体的には、今回使用している useRouter の push のモック関数を定義し、それを取得した本体の useRouter に上書きすることで、テストの際に使用される useRouter を定義することができます。

// useRouter の push メソッドをモックの関数として定義
const push = jest.fn();

jest.mock('next/router', () => {
  // next/router 本体を取得
  const router = jest.requireActual('next/router');
  // useRouter の部分だけを上書きする
  return {
    ...router,
    useRouter: () => {
      return {
        push,
      };
    },
  };
});

一方で、テストの内容としては「仮のモック関数が実行された」というもので、「TOPページに遷移する」というテストが行われているわけではないため、テストとして正しいのかという問題があります。
しかし、「TOPページに遷移する」という事象だけを切り取って見た際に、こちら側で行なっている実装としては、 router.push() のみになります。そこから考えると、「TOPページに遷移する」という部分はこちら側でテストを行うものではなく、 Next Router の実装側で行われる必要があるものになります。Next Router 側で機能として担保されていることを考慮すると、テストとして実装しなければならないのは、「Next Router の push メソッドが実行されている」というものになります。そのため、Integration Test として「仮のモック関数が実行された」というのは、テストとして正しいものになります。

Integration Test のリファクタリングと詳細実装

フォームなどの実装の場合、期待する値が未入力だった時の検証(バリデーション)が適切に行われているかという点もテストする必要があります。そして、そのような実装の場合、実装しなければならないテストは3パターンになります。

  1. フォームの処理が実行され、TOPページに遷移がされている

  2. メールアドレスが未入力だったため、エラーの表示とTOPページへの遷移が行われていない

  3. パスワードが未入力だったため、エラーの表示とTOPページへの遷移が行われていない

// view.tsx

import { useRouter } from 'next/router';
import { ChangeEvent, useCallback, useState } from 'react';

export const IntegrationTest: NextPage = () => {
  const router = useRouter();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [emailValidation, setEmailValidation] = useState<string | null>(null);
  const [passwordValidation, setPasswordValidation] = useState<string | null>(null);

  const onEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value);
  };

  const onPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
    setPassword(e.target.value);
  };

  const onSubmit = useCallback((e: ChangeEvent<HTMLFormElement>) => {
    e.preventDefault();

    if (email === '') {
      return setEmailValidation('メールアドレスを入力してください');
    }

    if (password === '') {
      return setPasswordValidation('パスワードを入力してください');
    }

    // ここで新規登録の処理
    router.push('/');
  }, [email, password, router]);

  const emailError = emailValidation && (
    <p>{ emailValidation }</p>
  );

  const passwordError = passwordValidation && (
    <p>{ passwordValidation }</p>
  );

  return (
    <main className={ styles.main }>
      <Head>
        <title>DebugIntegration</title>
      </Head>

      <form onSubmit={ onSubmit }>
        <div>
          <label htmlFor='email'>メールアドレス</label>
          <input
            id='email'
            type='email'
            value={ email }
            onChange={ onEmailChange }
          />
          { emailError }
        </div>
        <div>
          <label htmlFor='password'>パスワード</label>
          <input
            id='password'
            type='password'
            value={ password }
            onChange={ onPasswordChange }
          />
          { passwordError }
        </div>
        <button placeholder='新規登録'>新規登録</button>
      </form>
    </main>
  );
};

export default IntegrationTest;

そして、それを Integration Test として実装すると、以下のようになります。

// view.test.tsx

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

// .toBeInTheDocument()を使用する上で必要になります
import '@testing-library/jest-dom';

import { IntegrationTest } from './view';

// ...省略...

describe('Signup', () => {
  // モックの実行回数をリセットする
  afterEach(() => {
    jest.clearAllMocks();
  });

  test('新規登録成功', async () => {
    render(<IntegrationTest />);

	const email = screen.getByLabelText('メールアドレス') as HTMLInputElement;
    const password = screen.getByLabelText('パスワード') as HTMLInputElement;
    const button = screen.getByRole('button') as HTMLButtonElement;

    fireEvent.change(email, { target: { value: 'test@cyberagent.co.jp' } });
    fireEvent.change(password, { target: { value: 'Test1234' } });

    expect(email.value).toBe('test@cyberagent.co.jp');
    expect(password.value).toBe('Test1234');

    userEvent.click(button);

    await waitFor(() => {
      expect(push).toBeCalledTimes(1);
    });
  });

  test('メールアドレス未入力のエラー', async () => {
    render(<IntegrationTest />);

    const password = screen.getByLabelText('パスワード') as HTMLInputElement;
    const button = screen.getByRole('button') as HTMLButtonElement;

    fireEvent.change(password, { target: { value: 'Test1234' } });
    expect(password.value).toBe('Test1234');

    userEvent.click(button);

    await waitFor(() => {
      expect(screen.getByText('メールアドレスを入力してください')).toBeInTheDocument();
      expect(push).toBeCalledTimes(0);
    });
  });

  test('パスワード未入力のエラー', async () => {
    render(<IntegrationTest />);

    const email = screen.getByLabelText('メールアドレス') as HTMLInputElement;
    const button = screen.getByRole('button') as HTMLButtonElement;

    fireEvent.change(email, { target: { value: 'test@cyberagent.co.jp' } });
    expect(email.value).toBe('test@cyberagent.co.jp');

    userEvent.click(button);

    await waitFor(() => {
      expect(screen.getByText('パスワードを入力してください')).toBeInTheDocument();
      expect(push).toBeCalledTimes(0);
    });
  });
});

また、各テストの中で描画するコンポーネントや使用する要素が共通しているため、その辺りをまとめた形が以下のようになります。

// ...省略...

describe('Signup', () => {
  let email: HTMLInputElement;
  let password: HTMLInputElement;
  let button: HTMLButtonElement;

  beforeEach(() => {
    render(<IntegrationTest />);

    email = screen.getByLabelText('メールアドレス') as HTMLInputElement;
    password = screen.getByLabelText('パスワード') as HTMLInputElement;
    button = screen.getByRole('button') as HTMLButtonElement;
  });

  // モックの実行回数をリセットする
  afterEach(() => {
    jest.clearAllMocks();
  });

  test('新規登録成功', async () => {
    fireEvent.change(email, { target: { value: 'test@cyberagent.co.jp' } });
    fireEvent.change(password, { target: { value: 'Test1234' } });

    expect(email.value).toBe('test@cyberagent.co.jp');
    expect(password.value).toBe('Test1234');

    userEvent.click(button);

    await waitFor(() => {
      expect(push).toBeCalledTimes(1);
    });
  });

  test('メールアドレス未入力のエラー', async () => {
    fireEvent.change(password, { target: { value: 'Test1234' } });
    expect(password.value).toBe('Test1234');

    userEvent.click(button);

    await waitFor(() => {
      expect(screen.getByText('メールアドレスを入力してください')).toBeInTheDocument();
      expect(push).toBeCalledTimes(0);
    });
  });

  test('パスワード未入力のエラー', async () => {
    fireEvent.change(email, { target: { value: 'test@cyberagent.co.jp' } });
    expect(email.value).toBe('test@cyberagent.co.jp');

    userEvent.click(button);

    await waitFor(() => {
      expect(screen.getByText('パスワードを入力してください')).toBeInTheDocument();
      expect(push).toBeCalledTimes(0);
    });
  });
});

まとめ

今回の記事では、React Testing Library を使用した Integration Test の実装方法について紹介しました。今回の調査を通して、 Integration Test がどのようなもので、導入によって機能の安全性が向上することを学ぶことができました。また、テストの内容自体はそこまで複雑なものではなく、さまざまな機能に転用して使用できることがわかりました。そのため、今後も積極的に Integration Test を実装し、より安全で使いやすいサービスを目指していきたいと思います。

この記事が気に入ったらサポートをしてみませんか?