React テストしやすくリファクタリングする

2022/02/26に公開

Reactのリファクタリングノウハウの共有です

  • API
    • API呼び出しは独自SDKを作る
    • openapiをもとに型を生成する
    • API呼び出しに型を付ける
    • API呼び出しの例外をエラーに閉じ込める
    • 日付の扱い方について
  • ダミーデータ
    • APIの型データをもとにダミーデータを作成する
  • Hooks
    • 複雑なuseEffectはテストしにくいからモック化しよう
    • カスタムフックにするコツ

API編

リクエストまわりを以下にまとめてみました。
https://zenn.dev/dove/articles/c0bee1de387cc7

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に乗せておくだけでも便利だと思います。

.github/workflows/release.yml
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

このように使っています。

リソースの型情報

helpers/types
import { components } from '@yourgroup/youropenapi'; // 手動生成なら src/scheme.ts

export type Hoge = components['schemas']['Hoge'];
export type Fuga = components['schemas']['Fuga'];

リクエスト時の型安全性

HogeRequest
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まわりをカスタムフック化してモック化していくことをおすすめしたいです。

最初にコンポーネント作ったとして、

ChatIndex.tsx
export const ChatIndex: React.FC = () => {
  const [chats, setChats] = useState<Chat[]>([]);
  useEffect(() => {
    ChatRequest.fetch().then(res  => {
      setChats(res);
    })
  }, [])
  return <div>チャットページ: {chats.length}</div>
}

初期のテストは割と簡単

ChatIndex.test.tsx
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();
  });
  
 // ここにテストコード
]);

カスタムフックのテスト

https://zenn.dev/dove/articles/e00696be89a022

Discussion