現在あるReactプロジェクトでコンポーネントのUIテストを書いていますが、思わぬところでハマり焦りました。
以前JestまたはVitestとReact Testing LibraryでReactテスト環境の構築を書きましたが、サンプルアプリと本物のコードには、いろいろな違いがありテストも複雑になってしまいます。
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回実行されます。またit
やdescribe
の説明文字列がコンソールに表示されます。
"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のコードは
- catsステートが初期値(空配列)の状態で、初期表示(レンダリング)が行われます
- useEffect()が実行され、fetch()関数が起動されますが、データを取得する前に最初のレンダリングが行われます
- fetch()関数でデータが取得できると、その値がcatsステートに設定されます
- 新たな
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()
の中にあった事もありました。
悩んで色々な事を試していた時には変なコードを相手にしていたのかも知れません。やはり行き詰まったらいったんコードから離れて、頭を冷しリトライするのも重要かと思いました。😅