EY-Office ブログ

Vitest+React testing libraryで作るReactコンポーネントのテスト環境でハマった

現在あるReactプロジェクトでコンポーネントのUIテストを書いていますが、思わぬところでハマり焦りました。

以前JestまたはVitestとReact Testing LibraryでReactテスト環境の構築を書きましたが、サンプルアプリと本物のコードには、いろいろな違いがありテストも複雑になってしまいます。

Astro https://vitest.dev , https://testing-library.com , https://github.com/testing-library/react-testing-library から

テスト環境の構築

JestまたはVitestとReact Testing LibraryでReactテスト環境の構築と一部ダブりますが、Vitest(Vite) + Testing Library + React Testing Library + TypeScriptでUIテストの環境を作る情報やテストコードのサンプルはネット上にありますが、古いバージョンでの情報も含まれているようで、そのままでは動かない事もあります。

インストール手順等

インストールや設定ファイル設定は簡単です。

  • インストール
npm install --save-dev vitest jsdom @testing-library/react @testing-library/jest-dom
  • Vitest設定ファイル vitest.config.ts

Vitestの設定をvite.config.tsに書いている情報が多くありますが、現バージョンでは一緒に書けないようです(書く方法もあるようですが簡単ではないようです・・・)。

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/setupTests.ts'],
    globals: true
  }
});
  • テスト設定ファイル ./src/setupTests.ts
import matchers from "@testing-library/jest-dom/matchers";
import { cleanup } from "@testing-library/react";
import { afterEach, expect } from "vitest";

// extends Vitest's expect method with methods from react-testing-library
expect.extend(matchers);

// runs a cleanup after each test case (e.g. clearing jsdom)
afterEach(() => {
  cleanup();
});
  • TypeScript設定ファイルの変更 ./tsconfig.json

Vitest, Testing libraryの型定義ファイルを取り込ます。

{
  "compilerOptions": {
     ・・・
-    "noFallthroughCasesInSwitch": true
+    "noFallthroughCasesInSwitch": true,
+    "types": ["vitest/globals", "@testing-library/jest-dom"]
  },
}
  • テストコマンドの追加 ./package.json

これで、npm testでテストが1回実行されます。またitdescribeの説明文字列がコンソールに表示されます。

     "dev": "vite",
     "build": "tsc && vite build",
     "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
-    "preview": "vite preview"
+    "preview": "vite preview",
+    "test": "vitest run --reporter=verbose"
   },

今回のサンプルコード

前回のジャンケンアプリは単純なReactアプリでしたが、本番コードは以下のようなものを含んでいると思います。

  • ReduxやReact RouterのようなProviderコンポーネントを使っているライブラリー
  • API通信などの非同期処理

そこで、今回のサンプルコードでは、fetchを使った非同期通信とRecoilをステート管理に使っています。またThe Cat APIという猫の情報が取得出来るAPIサーバーを利用しています。

コードの説明はいらないですよね。😊

  • App.tsx
import { useEffect } from 'react'
import { atom, useRecoilState } from 'recoil';

type CatBreed = {
  id: string;
  name: string;
  temperament: string;
  description: string;
}

const catsState = atom<CatBreed[]>({
  key: 'catsState',
  default: []
});

const CatBreedAPI = "https://api.thecatapi.com/v1/breeds";

function App() {
  const [cats, setCats] = useRecoilState(catsState);

  useEffect(() => {
    (async () => {
      const response = await fetch(CatBreedAPI + "?limit=5");
      const data = await response.json();
      setCats(data);
    })();
  }, []);

  return (
    <>
    <h2>Cats</h2>
    {cats.map((cat) => (
      <dl key={cat.id}>
        <dt>Name:</dt><dd>{cat.name}</dd>
        <dt>Temperament:</dt><dd>{cat.temperament}</dd>
      </dl>
    ))}
    </>
  )
}

export default App
  • main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import { RecoilRoot } from 'recoil'


ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
     <RecoilRoot>
      <App />
     </RecoilRoot>
  </React.StrictMode>
)

テストコードの書き方

Providerはrenderのwrapperオプションが便利

テストコード内でコンポーネントのレンダーはrender(<App />);のようにrender関数で行いますが、当然RecoilのProviderが無いのでエラーになります。
render(<RecoilRoot><App /></RecoilRoot>);のように書けば動きますが、複数のProviderを使っている場合やそのオプションがある場合は、テストコードが無意味に長くなりよくありません。

このような場合は以下のように、render関数のwrapperオプションが便利です(wrapperコンポーネントは他のテストコードでも使うでしょうから、別ファイルにした方がよいですね)。

type RecoilWrapperProps = {
  children: React.ReactNode;
};
const RecoilWrapper: React.FC<RecoilWrapperProps> = ({children}) => {
  return (
    <RecoilRoot>{children}</RecoilRoot>
  );
}


it( ・・・

   render(<App />, { wrapper: RecoilWrapper });

・・・・

通信をモックに置き換える

VitestにはJestのモックと互換のモック機能があります。

今回はfetch()関数をモックに置き換え、テスト時には通信は行わずテストコードで指定した値を戻すようにします。

const fetchMock = vi.fn();    // ← ①
global.fetch = fetchMock;     // ← ②

const mockData = [{           // ← ③
  "id": "abys",
  "name": "Abyssinian",
  "temperament":"Active, Energetic, Independent, Intelligent, Gentle"
}];
                              // ↓ ④
fetchMock.mockResolvedValue({ json: () => new Promise((resolve) => resolve(mockData)) });
  • ① Vitestのモック生成
  • ② fetch()関数をモックで置き換え
  • ③ テスト時にfetch()関数が戻す値(アビシニアン種の猫の情報)
  • ④ fetch()関数の戻り値を設定、fetch()関数は非同期処理なのでPromiseを戻すmockResolvedValue()メッソドを使います。
    • fetch()関数の戻り値のjson()関数も非同期処理なので、Promiseを戻すコードを書きます

あれエラーになる!

以下のようなテストコードを実行するとエラーになりました。

  • ① React testing libraryのデバッグ支援機能でコンソールにレンダリングされたHTMLが表示されます
  • ② コンポーネントのレンダリング結果にAbyssinianという文字列がある事を確認しています
it('サーバーから取得されたデータが表示されている', () => {
  render(<App />, { wrapper: RecoilWrapper });
  screen.debug();                                              // ← ①
  expect(screen.getByText("Abyssinian")).toBeInTheDocument();  // ← ②
});

エラーはTestingLibraryElementError: Unable to find an element with the text: Abyssinian. で②が失敗しています。

コンソールに表示されるHTMLを見るとfetch()関数が戻している値が表示されていません??

<body>
  <div>
    <h2>
      Cats
    </h2>
  </div>
</body>

さて、今回のサンプルコードを見るとわかるようにこのReactのコードは

  1. catsステートが初期値(空配列)の状態で、初期表示(レンダリング)が行われます
    • useEffect()が実行され、fetch()関数が起動されますが、データを取得する前に最初のレンダリングが行われます
  2. fetch()関数でデータが取得できると、その値がcatsステートに設定されます
  3. 新たなcatsステートの値で再描画(レンダリング)が行われます

この流れから考えると、上のテストコードでは1.のレンダリング結果をテストしているようです。

ここから、ネット上の情報からいろいろ試したましたが、上手くいきませんでした。😅

動いた

結局、頭を冷やしてから以下のコードにしたところ動きました(今回は全テストコードを書きます)。

正解は、検証コードを②のようにawait waitFor()で括る事でした❗
これで、検証コードが成功するまで待って(リトライして)くれます。 そのために①のようにitに渡す無名関数はasyncにします。

import { render, screen, waitFor } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import App from "../src/App";

type RecoilWrapperProps = {
  children: React.ReactNode;
};
const RecoilWrapper: React.FC<RecoilWrapperProps> = ({children}) => {
  return (
    <RecoilRoot>{children}</RecoilRoot>
  );
}

const fetchMock = vi.fn();
global.fetch = fetchMock;

describe('App', () => {
  it('サーバーから取得されたデータが表示されている', async() => {  // ← ①
    const mockData = [{
      "id": "abys",
      "name": "Abyssinian",
      "temperament":"Active, Energetic, Independent, Intelligent, Gentle"
    }];
    fetchMock.mockResolvedValue({ json: () => new Promise((resolve) => resolve(mockData)) });

    render(<App />, { wrapper: RecoilWrapper });

    await waitFor(() => {                                // ← ②
      expect(screen.getByText("Abyssinian")).toBeInTheDocument();
      screen.debug();
    });
  });
});

コンソールには以下のように正しいHTMLが表示されています。

<body>
  <div>
    <h2>
      Cats
    </h2>
    <dl>
      <dt>
        Name:
      </dt>
      <dd>
        Abyssinian
      </dd>
      <dt>
        Temperament:
      </dt>
      <dd>
        Active, Energetic, Independent, Intelligent, Gentle
      </dd>
    </dl>
  </div>
</body>

 ✓ src/App.test.tsx (1)
   ✓ App (1)
     ✓ サーバーから取得されたデータが表示されている

 Test Files  1 passed (1)
      Tests  1 passed (1)

動いた(2)

以下のようにawait screen.findByText()を使う方法でもテストできます。

  it('サーバーから取得されたデータが表示されている', async() => {
    const mockData = [{
      "id": "abys",
      "name": "Abyssinian",
      "temperament":"Active, Energetic, Independent, Intelligent, Gentle"
    }];
    fetchMock.mockResolvedValue({ json: () => new Promise((resolve) => resolve(mockData)) });

    render(<App />, { wrapper: RecoilWrapper });

    expect(await screen.findByText("Abyssinian")).toBeInTheDocument();
    screen.debug();
  });

まとめ

上手く行かずに悩んでいるときにも、Testing LibraryのAsync Methodsを見て試した記憶がありますが、 その時点では上手くいきませんでした。

現在読むと、動いたテストコードはこの通りです。なぜ悩んでいた時は上手く行かなかったのでしょうか? ある時点までモックの設定やrender()beforeEach()の中にあった事もありました。

悩んで色々な事を試していた時には変なコードを相手にしていたのかも知れません。やはり行き詰まったらいったんコードから離れて、頭を冷しリトライするのも重要かと思いました。😅

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ