React カスタムhook のテストサンプル
カスタムhookのテストノウハウが溜まってきたので放出
想定カスタムhook
以下のカスタムhookを想定しています。マウントされるとAPIを叩いて、stateにデータを保持します。そこにローディング中のフラグと、ローカルstateを更新する関数を提供します。
// アラート
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
に入れ直しています。話がそれますが、例外って扱いにくいなと思うこの頃です。
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('カスタムフックの関数');
})
上記カスタムフックを実際に適応するとこうなります。
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テストの正常系です。
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
型のダミーデータを生成しています。人力です。
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()
のような、何でもいいような値を扱えます。
問題は、setIsLoading
とsetComments
の順番が前後してしまうと、テストが壊れてしまうことです。これはまずはsetIsLoading
を呼ぶという規約を決めないといけません。
初期fetch失敗
こちらは簡単です。jestのspyやモック機能を使って、CommentRequest.fetch()
を失敗させてあげればよいだけです。
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
で囲むことです。
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