こちらは株式会社POLテックカレンダー2021 13日目の記事になります。
前回の記事はこちら
こんにちは。POLでフロントエンドを担当している根岸です。
今回は状態管理ライブラリRecoilについて書いていこうと思います。
Recoilも徐々にバージョンが上がってきて使いやすくなってきましたね!
個人的にはRefresherの機能が追加されたのが嬉しいです!
ここが使いやすくなってくれるとありがたいなぁ〜なんて思っていました。今後に期待。
前置き
さて今回はRecoilの特徴的な機能の1つ非同期Selectorについて考えていこうと思います。
以下のコードはRecoilのドキュメントのAsynchronous Exampleから持ってきたものです。
こちらのコード、Selectorと非同期ロジックが依存してしまっていてちょっと扱いづらいなって思いませんか?
あくまでサンプルなのでシンプルな実装になっていますが、
そのまま使ってしまうと後々困りそうで、テストコードなんかを書こうとするとウッ・・となりますね。
なので今回は使いやすい実装を考えていこうと思います。
const currentUserNameQuery = selector({
key: 'CurrentUserName',
get: async ({get}) => {
const response = await myDBQuery({
userID: get(currentUserIDState),
});
return response.name;
},
});
使いやすい非同期Selectorを考えていく
ユーザー情報をAPIから取ってきて画面に表示するまでを例に考えていきましょう。
1. APIからユーザー情報を取得する処理を定義する
まずは非同期処理部分をServiceとして分離していきます。
クリーンアーキテクチャを採用しているシステムなどではユースケースに置き換えるなどしてください。
Model
今回はIDと名前だけのシンプルなユーザー情報を定義。
export type User = {
id: number;
name: string;
};
Api
axiosなどのHTTP通信部分をWrapする用のクラスを作成。
export interface IApi {
get<T>(url: string): Promise<T>;
}
export class Api implements IApi {
get<T>(url: string) {
// 通信処理を記述する
return axios.get<T>(url)
}
}
Api Service
Apiからユーザー情報を取得するクラスを作成。
export interface IUserApiService {
getUser(id: number): Promise<User>;
}
export class UserApiService implements IUserApiService {
constructor(private api: IApi) {}
async getUser(id: number) {
const user = await this.api.get<User>(`user/${id}`);
return user;
}
}
2. Stateの定義をする
ApiService用Atomを定義する
まずは以下のようにApiServiceを返すAtomを作ってあげます。
atomのジェネリクスにはApiServiceのinterfaceを指定してあげましょう。
具体なクラスを指定してしまうと結合度が高まってしまうので意味がなくなってしまいます。
export const userApiServiceAtom = atom<IUserApiService>({
key: 'atom/service/user',
default: new UserApiService(new Api()),
});
非同期Selectorを定義する
次に非同期Selectorの中でApiServiceのAtomを呼んであげるようにしましょう。
Atom経由でserviceを取得するので、Selectorをinterfaceに依存させることができます。
今回はApiServiceをAtomとして定義していますが、
クリーンアーキテクチャを採用しているシステムなどではUseCaseに置き換えてあげると良いと思います。
export const userSelector = selectorFamily<User, number>({
key: 'selector/user',
get: (id: number) => async ({ get }) => {
const apiService = get(userApiServiceAtom);
const user = await apiService.getUser(id);
return user;
},
});
3. コンポーネントを作成する
コンポーネントで非同期Selectorを呼び出す
後はSelectorからユーザー情報を取得して反映するだけ完了です。
type Props = {
id: number
}
export const UserInfo: React.FC<Props> = ({ id }) => {
const user = useRecoilValue(userSelector(id));
return (
<Table>
<tr>
<th>ID</th>
<th>名前</th>
</tr>
<tr>
<td data-testid="user_id">{user.id}</td>
<td data-testid="user_name">{user.name}</td>
</tr>
</Table>
);
};
テストコードを書いていく
使いやすくしたので、テストコードも書いていきましょう。
1. コンポーネントのテスト
モックの作成
まずはApiServiceのモックを作ってあげます。
今回は成功パターンとして適当にUser情報を返してあげます。
異常系を確認したい場合は、エラーをthrowしてあげてくださいね。
const getUserMock = jest
.fn()
.mockImplementation(() => Promise.resolve({ id: 100, name: 'test user' }));
モックの注入
作成したモックをAtomに注入していきましょう。
RecoilRoot
のinitializeState
を利用することで初期値の設定をすることができます。
引数でset
関数を受け取ることができるので、それを利用してモックに置き換えてあげましょう。
render(
<RecoilRoot
initializeState={({ set }) => {
// mockに置き換える
set(userApiServiceAtom, { getUser: getUserMock });
}}
>
<React.Suspense fallback="">
<UserInfo id={100} />
</React.Suspense>
</RecoilRoot>
);
これでモック化は完了です。簡単ですね!
検証する
後はモックが返した値がコンポーネントされていることを検証できればOKになります。
丁寧にテストするのであればモック関数が期待通りに実行されていることも確認しておきましょう。
// 値が反映されていることを検証
expect(screen.getByTestId('user_id').textContent).toBe('100');
expect(screen.getByTestId('user_name').textContent).toBe('test user');
// サービスが期待通りに実行されていることを確認
expect(getUserMock).toHaveBeenCalledTimes(1);
expect(getUserMock).toBeCalledWith(100);
テストコード全体
テストコード
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { UserInfo } from '../UserInfo';
import { userApiServiceAtom } from '../state';
describe('Test UserInfo.', () => {
test('useCaseから取得した値が反映される', async () => {
const getUserMock = jest
.fn()
.mockImplementation(() => Promise.resolve({ id: 100, name: 'test user' }));
render(
<RecoilRoot
initializeState={({ set }) => {
set(userApiServiceAtom, { getUser: getUserMock });
}}
>
<React.Suspense fallback="">
<UserInfo id={100} />
</React.Suspense>
</RecoilRoot>
);
await waitFor(() => {
expect(screen.getByTestId('user_id').textContent).toBe('100');
expect(screen.getByTestId('user_name').textContent).toBe('test user');
});
expect(getUserMock).toHaveBeenCalledTimes(1);
expect(getUserMock).toBeCalledWith(100);
});
});
2. Hooksのテスト
まずはテスト用のHooksを作る
テスト用に以下のようなシンプルなHooksを作成します。
Selectorから取得したユーザー情報を文字列にして返すシンプルなHooksです。
export const useUserInfo = (id: number) => {
const user = useRecoilValue(userSelector(id));
return `${user.id}: ${user.name}`;
};
モックの注入
Hooksのテストはコンポーネントのテストとほとんど変わりません。
同様に簡単に書くことができます。
@testing-library/react-hooks
利用してテストしていきます。
renderHook
のwrapper
にモック注入するRecoilRoot
を設定してあげましょう。
これで完了です!簡単!
const { result, waitForNextUpdate } = renderHook(() => useUserInfo(1), {
wrapper: ({ children }) => {
return (
<RecoilRoot
initializeState={({ set }) => {
set(userUseCaseAtom, { getUser: getUserMock });
}}
>
{children}
</RecoilRoot>
);
},
});
テストコード全体
コードを見る
test('Test useUserInfo', async () => {
const getUserMock = jest
.fn()
.mockImplementation(() =>
Promise.resolve({ id: 100, name: 'test user' })
);
const { result, waitForNextUpdate } = renderHook(() => useUserInfo(1), {
wrapper:({ children }) => {
return (
<RecoilRoot
initializeState={({ set }) => {
set(userUseCaseAtom, { getUser: getUserMock });
}}
>
{children}
</RecoilRoot>
);
},
});
await waitForNextUpdate();
expect(result.current).toBe('100: test user');
});
まとめ
今回は非同期Selectorの作成からテストコードまでを書いてみました。
もっと良い方法はありそうだなと思いつつ、現時点ではこれが一番良さそうでした。
今後色々使ってみて良い方法を模索できればと思います。
まだまだRecoilも試行錯誤段階なので
もっと議論が進んで良いプラクティスが出てくることに期待です!楽しみですね!
次回はmotikomaさんになります!
よろしくお願いします!