React テストしやすくリファクタリングする
Reactのリファクタリングノウハウの共有です
- API
- API呼び出しは独自SDKを作る
- openapiをもとに型を生成する
- API呼び出しに型を付ける
- API呼び出しの例外をエラーに閉じ込める
- 日付の扱い方について
- ダミーデータ
- APIの型データをもとにダミーデータを作成する
- Hooks
- 複雑なuseEffectはテストしにくいからモック化しよう
- カスタムフックにするコツ
API編
リクエストまわりを以下にまとめてみました。
API呼び出しは独自SDKを作る
例えば useEffect
でコンポーネントマウント時にAPIリクエストを送るときや、ボタンを押下時にいデータをPOSTするときに、直接 axios
を叩かず、関連するリソースをモジュール化する方法です。
before
useEffect(() => {
axios.get('???');
}, []);
after
class Hoge {
static async fetch(){
axios.get('???');
}
}
useEffect(() => {
HogeRequest.fetch().then(res => {
setData(res);
})
}, []);
メリット
- 独自SDKにすることで、他のコンポーネントでも使いまわししやすくなる
- 直接APIを叩かずに1枚皮をかぶせることで、APIの仕様変更に対応しやすくする
- jestでモック化する際にモジュールとメソッドを指定するだけでいいのでより直感的にモック化できる。
デメリット
- SDK自体をテストする必要がある
openapiをもとに型を生成する&API呼び出しに型を付ける
openapiドキュメントを作っている場合、そこから自動的に型生成できると楽ですね。openapiドキュメントからSDKを自動生成してくれるツールまであるみたいです。僕はバックエンドもTSを採用しているため、単に型情報のみほしかったのでopenapi-typescriptというものを利用しています。openapiドキュメントをgit管理し、GitHub Actioinsでpushがあればnpmモジュールとして登録することで、フロントからもバックエンドからも利用できるようにしました。ここまでしなくてもローカルでnpx openapi-typescript youropenapi.latest.yaml --output src/scheme.ts
と叩いて生成されるスキーマファイルをgitに乗せておくだけでも便利だと思います。
name: auto release
on:
push:
# mainブランチにコミットがpushされたときに限定
branches:
- master
# 上記条件に加えてyouropenapi.latest.yamlが変更されたときのみという条件を追加
paths:
- youropenapi.latest.yaml
- .github/workflows/release.yml
jobs:
auto-release:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_IT_VERSION: 14.2.1
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # github packagesのときはNPM_TOKENではなくGITHUB_TOKEN
steps:
- name: Check out codes
uses: actions/checkout@v2
with:
fetch-depth: 0
- uses: actions/setup-node@v2
with:
node-version: '14.x'
# githubをnpmサーバーにする
registry-url: 'https://npm.pkg.github.com'
# Defaults to the user or organization that owns the workflow file
scope: '???' #あなたの団体
- name: generate scheme from openapi document
run: npx openapi-typescript youropenapi.latest.yaml --output src/scheme.ts
- name: Set releaser settings
run: |
git config --global user.name release-machine
git config --global user.email email@example.com
- name: Major release
id: major
if: contains(toJSON(github.event.commits.*.message), 'bump up version major')
run: npx release-it@${RELEASE_IT_VERSION} -- major --ci
- name: Minor release
id: minor
# メジャーバージョンアップをしていないときマイナーバージョンアップを行なうか
if: steps.major.conclusion == 'skipped' && contains(toJSON(github.event.commits.*.message), 'bump up version minor')
run: npx release-it@${RELEASE_IT_VERSION} -- minor --ci
- name: Patch release
# コミットメッセージに特に指定がない場合はマイナーバージョンを更新する
if: "!(steps.major.conclusion == 'success' || steps.minor.conclusion == 'success')"
run: npx release-it@${RELEASE_IT_VERSION} -- patch --ci
このように使っています。
リソースの型情報
import { components } from '@yourgroup/youropenapi'; // 手動生成なら src/scheme.ts
export type Hoge = components['schemas']['Hoge'];
export type Fuga = components['schemas']['Fuga'];
リクエスト時の型安全性
import { operations } from '@yourgroup/youropenapi.openapi'; // 手動生成なら src/scheme.ts
class Hoge {
static async fetch(){
axios.get<operations['get-hoge']['responses']['200']['content']['application/json']>('???');
}
}
ただしcomponents
として取り出すためにはopenapiのModelという記述の仕方をして、レスポンスにrefでそのモデルを指定して上げる必要があります。
API呼び出しの例外をエラーに閉じ込める
リクエスト時のエラーは2種類のみです。
- ネットワークエラー(タイムアウトとか)
- サーバーエラー(400系,500系のステータスコード)
axiosを使っている場合、ネットワークエラーは例外として扱い、サーバーエラーはレスポンスデータなので返り値として扱うことになります。TSの例外はcatch句の型まわりがよわく、またtry-catch構文はコードを複雑にさせ見通しが悪くなります。もしネットワークエラーもサーバーエラーと同じように返り値で一緒に扱えば、例外を気にしなくていいのでかなり使いやすくなります。
日付の扱いについて
JSにはDateクラスがありますが、残念ながらHTTPリクエストにはDate型はありません。あるのはstringとbooleanとnumberです。日付の型はAPIにあわせてフロント内部でもstringとして取り回しましょう。もしDate型の役割が必要となればそこでnew Date()
で囲ってあげればいいのです。
ちなみに一般的には日付はISO8601形式の文字列で扱うことが多いみたいです。
Date
からISO8601
にするにはtoISOString()
を用います。
new Date().toISOString();
ISO8601
からDate
にするには単にnew Date()
時に囲むだけです。
new Date('20220208T210830+0900');
ダミーデータ
ダミーデータを作るとテストが一気に楽になります。以下はチュートリアル画面のユニットテストです。型情報はopenapiをもとに生成しています。ProviderWrapper
はreduxのstoreを入れるためのラッパーです。
test('チュートリアル状態', () => {
const currentUser = dummyModel.buildCurrentUser({
status: 'TUTORIAL',
});
render(
<MemoryRouter>
<ProviderWrapper
options={{
currentUser: {
data: currentUser,
},
}}
>
<Tutorial />
</ProviderWrapper>
</MemoryRouter>,
);
expect(
screen.getByText(
/チュートリアル画面/,
),
).toBeInTheDocument();
});
import {
CurrentUser,
UserStatus,
} from 'helpers/types';
import faker from 'faker';
export const dummyModel = {
buildCurrentUser: (options?: Partial<CurrentUser>): CurrentUser => {
return {
id: options?.id ?? faker.datatype.number(),
name: options?.name ?? faker.name.firstName(),
status: options?.status ?? UserStatus.ACTIVE,
};
},
}
Hooks
複雑なuseEffectはテストしにくいからカスタムフック作ってモック化しよう
Reactのコンポーネントテストを難しくさせているのは複雑なuseEffectです。一般的なテストプラクティスはhook含めてなるべくモック化せずにテストすることを推奨しています。しかし、僕はこれに半分反対です。というのはテスト導入期にはテストノウハウは存在せず、そこにuseEffectが原因でテストのハードルが上がり結果テストが導入できない危険性があるからです。テストの知見がまだないチームは積極的にuseEffectまわりをカスタムフック化してモック化していくことをおすすめしたいです。
最初にコンポーネント作ったとして、
export const ChatIndex: React.FC = () => {
const [chats, setChats] = useState<Chat[]>([]);
useEffect(() => {
ChatRequest.fetch().then(res => {
setChats(res);
})
}, [])
return <div>チャットページ: {chats.length}件</div>
}
初期のテストは割と簡単
import { MemoryRouter } from 'react-router-dom';
import { ChatIndex } from './ChatIndex';
describe('<ChatIndex />', () => {
afterAll(() => {
jest.resetAllMocks().restoreAllMocks();
});
// ここにテストコード
]);
でもここに、ローディング中の状態を保持したいときや、チャットを削除してローカルの状態も削除したいときとか、もしくはfetchし直したいときとか、複雑になってくるとテストが大変になります。うまくテストの順序考えないとnot wrapped in act(...)
ワーニングがでてきててんやわんやします。
これらすべてをカスタムフック化してモックすれば、状態変化を閉じ込めることができるので再レンダリングを起こしません。
どうやってカスタムフックを作るのか
役割駆動開発をおすすめしています。役割ごとにhooksを集めると自然とカスタムフックが作れます。
import * as defaultUseHoge from 'helpers/hooks/useHoge';
import { MemoryRouter } from 'react-router-dom';
import { ChatIndex } from './ChatIndex';
const useHogeSpy = jest.spyOn(defaultUseHogeQuery, 'useHogeSpy');
describe('<ChatIndex />', () => {
beforeEach(() => {
const CHAT_TOTAL = 21;
const mockChats = dummyModel.buildChats(CHAT_TOTAL);
useHogeSpy.mockReturnValue({
chats: mockChats,
total: CHAT_TOTAL,
isLoading: false,
});
});
afterAll(() => {
jest.resetAllMocks().restoreAllMocks();
});
// ここにテストコード
]);
カスタムフックのテスト
Discussion