🧪

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

2023/04/16に公開

はじめに

最近、単体テストの考え方/使い方[1]を読む機会があり、その中で学んだAAAパターンが画期的だったので、共有したいと思います。
BrunchMadeのホームページを例に、期待される動作や挙動に応じた単体テストの書き方を紹介します。

対象読者

  • jestやtesting-libraryを使ってコンポーネントやカスタムフックテストを書き方はわかる
  • 単体テストは何を書くべきかあまりわかっていない
  • 単体テストの恩恵を受けれているか不安

上記は単体テストの考え方/使い方を読む前までの自分です。同じ状況にある方々が、本稿を通して少しでも役に立てることを願っています。

AAAパターン

私自身テストが書けるようになったのは良いものの、統一感のない記述方法に少し不安を覚えていました。その時に出会ったのがAAAパターンでした。
AAAパターンとは、テストケースの構造に関するパターンのことです。

  1. Arrange:準備
    テストケースの事前条件を満たすためにテスト対象システム(オブジェクト)とその依存の状態を設定するフェーズ。
  2. Act:実行
    テスト対象システムのメソッドを呼び出すことで、テスト対象の振る舞いを実行するフェーズ。そのメソッドが戻り値を持つ場合は変数等で保持しておく必要がある。
  3. Assert:確認
    実行結果が想定した結果であることを確認するフェーズ。ここでいう実行結果とは、戻り値でもあればテスト対象システムやその協力者オブジェクトの実行後の状態。もしくはメソッドの呼び出されたことを対象とする。

AAAパターンを用いることで、テストケースに対して簡潔かつ統一性の高い構造を持たせることができます。

具体的なコードを見ていきましょう。

カスタムフックの単体テスト

useDisclosure.ts
export const useDisclosure = (initial = false) => {
  const [isOpen, setIsOpen] = useState(initial);

  const open = useCallback(() => setIsOpen(true), []);

  const close = useCallback(() => setIsOpen(false), []);

  return { isOpen, open, close };
};

責任:UIの開閉を管理するカスタムフック(モーダルやダイアログ等)

useDisclosure.test.ts
import { renderHook, act } from '@testing-library/react';

import { useDisclosure } from '../useDisclosure';

describe('useDisclosureフックのテスト', () => {
  test('1️⃣引数のinitialがisOpenの初期値に設定される', () => {
    // 準備
    const initital = true
    // 実行
    const { result } = renderHook(() => useDisclosure(initital));
    // 確認
    expect(result.current.isOpen).toBe(true);
  });

  test('2️⃣open関数を実行すると、isOpenがtrueになる', () => {
    // 準備
    const { result } = renderHook(() => useDisclosure());
    // 実行
    act(() => result.current.open());
    // 確認
    expect(result.current.isOpen).toBe(true);
  });

  test('3️⃣close関数を実行すると、isOpenがfalseになる', () => {
    // 準備
    const { result } = renderHook(() => useDisclosure());
    // 実行
    act(() => result.current.close());
    // 確認
    expect(result.current.isOpen).toBe(false);
  });
});

実行フェーズの違い
1️⃣は初期レンダリング時の挙動をテスト、2️⃣・3️⃣はカスタムフックが提供する関数をテストするため、実行フェーズが異なります。

準備・実行フェーズの短縮化
AAAをわかりやすくするために上記のような書き方をしていますが、1️⃣のようなシンプルなテストであれば準備と実行はまとめて記述しても良いと考えています。

test('1️⃣引数のinitialがisOpenの初期値に設定される', () => {
  // 準備 & 実行
  const { result } = renderHook(() => useDisclosure(true));
  // 確認
  expect(result.current.isOpen).toBe(true);
});

コンポーネントの単体テスト

問い合わせフォームのContactFormコンポーネントの単体テスト例です。
ここでは、前述で紹介したuseDisclosureを使用して確認ダイアログが表示されるかどうかのテストを例に挙げていきます。

テストケースの操作例

ContactForm.tsx
import { Stack, styled, TextField } from '@mui/material';
import { memo, useCallback } from 'react';

import { Button } from '@/components/atoms/button';
import { useDisclosure } from '@/hooks/useDisclosure';

import { ContactConfirmDialog } from '../contactConfirmDialog';

const StyledTextField =styled(TextField)(({ theme }) => ({
  '& .MuiFormLabel-asterisk': {
      color: theme.palette.warning.main,
  },
}))

export const ContactForm: React.FC = memo(() => {
  const { isOpen, open, close } = useDisclosure();

  const handleClick = useCallback(() => {
    open();
  }, [open]);

  return (
    <>
      <Stack spacing={2} bgcolor='#fff' p={3}>
        <StyledTextField label='お名前'/>
        <StyledTextField label='フリガナ'/>
        <StyledTextField label='メールアドレス'/>
        <TextField label='電話番号'/>
        <TextField label='貴社名'/>
        <StyledTextField label='お問い合わせ内容'/>
        <Button color='secondary' onClick={handleClick}>
          送信内容を確認する
        </Button>
      </Stack>
      <ContactConfirmDialog open={isOpen} onClose={close} />
    </>
  );
});

責任:問い合わせフォームの入力と入力した内容の確認ダイアログを表示する

ContactForm.test.tsx
describe('ContactFormコンポーネントのテスト', () => {
  describe('ContactConfirmDialogの開閉テスト', () => {
    const setUp = (): HTMLElement => {
      render(<ContactForm />);
      const button = screen.getByRole('button', { name: '送信内容を確認する' });
      return button
    }
    
    it('「送信内容を確認する」ボタンをクリックした時、ContactConfirmDialogが開く', async () => {
      // 準備
      const button = setUp();
      // 実行
      await userEvent.click(button);
      // 確認
      const dialog = screen.getByRole('dialog');
      expect(dialog).toBeVisible();
    });
    
    it('ContactConfirmDialog表示後、ダイアログの背景をクリックするとContactConfirmDialogが閉じる', async () => {
      // 準備
      const button = setUp();
      await userEvent.click(button);
      const backdrop = screen.getAllByRole('presentation')[1];
      // 実行
      await userEvent.click(backdrop);
      // 確認
      const dialog = screen.queryByRole('dialog');
      expect(dialog).not.toBeVisible();
    });
  });
});

setUp関数
ダイアログの開閉を操作をする時には、「送信内容を確認する」ボタン要素を取得が必須になるため、setUp関数で共通化しました。
準備フェーズはAAAパターンの中で最も大きくなるフェーズです。
テストケース間で準備フェーズのコードが同じものを使用されるのであれば、共通化するのも可読性を高める1つの方法です。
今回のケースはtesting-libraryの例を元に考えました。
https://testing-library.com/docs/example-input-event
他にもオブジェクト・マザーテスト・データ・ビルダーと呼ばれるパターンもあります。

AAAパターンのデザイン・アンチパターン

🙆‍♂️デザインパターン

基本的にArrangemActAssertの順番が適切です。

🙅‍♂️アンチパターン

同じフェーズが複数あるケースは1度に多くのことを検証しようとしている可能性が高いです。
基本的に単体テストの範囲は1単位の振る舞い[2]毎に分割して考えた方が可読性が高く、何をテストしているのかが明確になります。

おわりに

最後までお読みいただきありがとうございました!
単体テストの考え方/使い方はAAAパターン以外に単体テストの定義やモックのベストプラクティス、結合・E2Eなどのテスト手法も取り上げているのでオススメです📕
また、今回はAAAパターンを紹介しましたが、似た様なパターンにGiven-When-Thenパターンがあります。
個人的にこちらの記事がわかりやすかったので(勝手に)共有させていただきます!
https://zenn.dev/m10maeda/articles/gwt-might-feel-more-natural-than-3a-for-ui-testing

脚注
  1. 書籍JANコード:ISBN:978-4-8399-8172-3 ↩︎

  2. 「単体」の定義はロンドン学派古典学派がありますが、本稿では単体テストの考え方/使い方で推奨している古典学派の定義を採用しています ↩︎

Discussion