💯

React カスタムhook のテストサンプル

2022/02/16に公開

カスタムhookのテストノウハウが溜まってきたので放出

想定カスタムhook

以下のカスタムhookを想定しています。マウントされるとAPIを叩いて、stateにデータを保持します。そこにローディング中のフラグと、ローカルstateを更新する関数を提供します。

useCommentQuery.tsx
// アラート
import notify from 'helpers/notify';
// 独自のaxiosのラッパー。api呼び出し。
import { CommentRequest } from 'helpers/requests/commentRequest';
// 今回使用したapiのデータの型
import { Comment } from 'helpers/types';
import { useEffect, useState } from 'react';

export const useCommentQuery = (
  chatId: number | null,
): {
  comments: Comment[];
  isLoading: boolean;
  appendComment: (comment: Comment) => void;
} => {
  const [comments, setComments] = useState<Comment[]>([]);
  const [isLoading, setIsLoading] = useState(false);

  const appendComment = (comment: Comment) => {
    return setComments((prev) => [...prev, comment]);
  };
  
  useEffect(() => {
    let isUnmounted = false;

    const load = async (): Promise<void> => {
      setComments([]);
      setIsLoading(true);
      if (!chatId || chatId <= 0 || Number.isNaN(chatId)) {
        return;
      }

      const res = await CommentRequest.fetch({
        chatId,
      });

      if (isUnmounted) {
        return;
      }
      setIsLoading(false);

      if (!res.isSuccess) {
        // アラート表示
        notify({ status: 'error', message: res.error });

        return;
      }

      setComments(res.body);
    };

    load();

    return () => {
      isUnmounted = true;
    };
  }, [kaseId]);

  return { comments, isLoading, appendComment };
};

CommentRequestとは独自のaxiosのラッパーです。以下の型がかえってきます。例外は投げずすべてのエラーや例外をerrorに入れ直しています。話がそれますが、例外って扱いにくいなと思うこの頃です。

helpers/requests/CommentRequest.ts
export type ResponseAPI<Body = unknown, Header = unknown> =
  | {
      isSuccess: true;
      body: Body; // これbodyがないとき、body: undefinedって指定しないといけないので不便。なにか他に方法ないのか。
      header: Header;
    }
  | {
      isSuccess: false;
      error: string;
    };

notifyはアラートです。説明は省略します。

テストファイルのdescribe構成

describe('カスタムフック名', () => {
  beforeEach(() => {
    // spy系
  })
  describe('初期fetch', () => {
    test.todo('happy path');
    test.todo('fetch失敗');
  })
  
  test.todo('カスタムフックの関数');
})

上記カスタムフックを実際に適応するとこうなります。

useCommentQuery.test.tsx
import { renderHook, act } from '@testing-library/react-hooks';
import { CommentRequest } from 'helpers/requests/commentRequest';

import { useCommentQuery } from './useCommentQuery';

const commentRequestSpy = jest.spyOn(CommentRequest, 'fetch');

describe('useCommentQuery', () => {
  beforeEach(() => {
    commentRequestSpy.mockResolvedValue({
      isSuccess: true,
      body: [],
      header: undefined,
    });
  });
  
  describe('初期fetch', () => {
    test.todo('happy path');

    test.todo('fetch失敗');
  });

  test.todo('appendComment()');
});

初期fetchのテスト

ここから個別に詳細を見ていきます。まずは初期fetchテストの正常系です。

useCommentQuery.test.tsx
import { renderHook, act } from '@testing-library/react-hooks';
import { CommentRequest } from 'helpers/requests/commentRequest';
import faker from 'faker';
import { dummyModel } from 'helpers/dummyModel';
import { useCommentQuery } from './useCommentQuery';

const commentRequestSpy = jest.spyOn(CommentRequest, 'fetch');

describe('useCommentQuery', () => {
  beforeEach(() => {
    commentRequestSpy.mockResolvedValue({
      isSuccess: true,
      body: [],
      header: undefined,
    });
  });
  
  describe('初期fetch', () => {
    test('happy path', async () => {
      const chatId = faker.datatype.number(); // ランダム数字
      const comment = dummyModel.buildComment(); // ダミーデータ

      commentRequestSpy.mockResolvedValue({
        isSuccess: true,
        body: [comment],
        header: undefined,
      });

      // レンダリング
      const { waitForNextUpdate, result } = renderHook(() =>
        useCommentQuery(chatId),
      );

      // 更新待ち
      await waitForNextUpdate();

      expect(result.all).toHaveLength(4);
      expect(result.all).toMatchObject([
        {
          comments: [],
          isLoading: false,
          appendComment: expect.anything(),
        },
        {
          comments: [],
          isLoading: true,
          appendComment: expect.anything(),
        },
        {
          comments: [],
          isLoading: false,
          appendComment: expect.anything(),
        },
        {
          comments: [comment],
          isLoading: false,
          appendComment: expect.anything(),
        },
      ]);
    });
  });
});

fakerはダミーデータ生成ライブラリです。最近ひと悶着ありました。説明は省略します。

dummyModelは独自クラスです。Comment型のダミーデータを生成しています。人力です。

helpers/dummyModel.ts
  buildComment: (options?: Partial<Comment>): Comment => {
    return {
      content: options?.content ?? faker.lorem.sentence(),
      userId: options?.userId ?? faker.datatype.number(),
      createdAt: options?.createdAt ?? faker.date.past().toISOString(),
    };
  },

isLoadingなどがちゃんと変わっているかどうかをテストしたかったのですが、うまくできなかったので、result.allを使っています。これは今までの返り値の履歴データが全て入ってます。

The all value is an array containing all the returns (including the most recent) from the callback. These could be result or an error depending on what the callback returned at the time.
https://react-hooks-testing-library.com/reference/api#renderhook

jestのtoMatchObjectというマッチャーを使えば、expect.anything()のような、何でもいいような値を扱えます。
https://jestjs.io/ja/docs/expect#tomatchobjectobject

問題は、setIsLoadingsetCommentsの順番が前後してしまうと、テストが壊れてしまうことです。これはまずはsetIsLoadingを呼ぶという規約を決めないといけません。

初期fetch失敗

こちらは簡単です。jestのspyやモック機能を使って、CommentRequest.fetch()を失敗させてあげればよいだけです。

useCommentQuery.test.tsx
import { renderHook, act } from '@testing-library/react-hooks';
import { CommentRequest } from 'helpers/requests/commentRequest';
import faker from 'faker';
import { dummyModel } from 'helpers/dummyModel';
import { useCommentQuery } from './useCommentQuery';

const commentRequestSpy = jest.spyOn(commentRequest, 'fetch');

describe('useCommentQuery', () => {
  beforeEach(() => {
    commentRequestSpy.mockResolvedValue({
      isSuccess: true,
      body: [],
      header: undefined,
    });
  });
  
  describe('初期fetch', () => {
    test('happy path', async () => { 
      // 省略
    });
    
    test('fetch失敗', async () => {
      const chatId = faker.datatype.number();
      commentRequestSpy.mockResolvedValue({
        isSuccess: false,
        error: '失敗',
      });

      const { waitForNextUpdate, result } = renderHook(() =>
        useCommentQuery(chatId),
      );

      await waitForNextUpdate();

      expect(result.current.comments).toEqual([]);
      expect(result.current.isLoading).toEqual(false);
    });
  });
});

カスタムhookの関数

ポイントはカスタムフックの関数をactで囲むことです。

useCommentQuery.test.tsx
import { renderHook, act } from '@testing-library/react-hooks';
import { CommentRequest } from 'helpers/requests/commentRequest';
import faker from 'faker';
import { dummyModel } from 'helpers/dummyModel';
import { useCommentQuery } from './useCommentQuery';

const commentRequestSpy = jest.spyOn(commentRequest, 'fetch');

describe('useCommentQuery', () => {
  beforeEach(() => {
    commentRequestSpy.mockResolvedValue({
      isSuccess: true,
      body: [],
      header: undefined,
    });
  });
  
  describe('初期fetch', () => {
    // 省略
  });
  
  test('appendComment()', async () => {
    const chatId = faker.datatype.number();
    const comment = dummyModel.buildComment();

    // 何も入っていない
    commentRequestSpy.mockResolvedValue({
      isSuccess: true,
      body: [],
      header: undefined,
    });

    const { waitForNextUpdate, result } = renderHook(() =>
      useCommentQuery(chatId),
    );

    await waitForNextUpdate();

    // 追加
    act(() => {
      result.current.appendComment(comment);
    });

    expect(result.current.comments).toEqual([comment]);
  });
});

Discussion