環境
- macOS Monterey 12.6
- VSCode
- node.js v18.6.0
- npm 8.13.2
- Typescript ^5.0.2
- vite ^4.4.5
- vitest ^0.34.2
前置き
本記事で説明すること
以下の記事で関数テストの環境構築と実装、テストの作成、パスエイリアス、カバレッジ設定まで完了した。本記事では、Reactコンポーネントのテスト環境構築、コンポーネントの実装とテストの作成を行う
【Vitestで始める単体・結合テスト】関数テストの環境構築と作成
vueを使用したケースはこちら
【Vitestで始める単体・結合テスト】VueコンポーネントとComposable関数のテストの環境構築と作成(testing-library/vue)
【Vitestで始める単体・結合テスト】Vueコンポーネントテストの環境構築と作成(vue-test-utils)
コンポーネントテスト全般のTIPSなどまとめはこちら
【Vitestで始める単体・結合テスト】保守性の高いテストのTIPSやポイント
構成
React Testing Library
- コンポーネントテスト用
- DOMのレンダー、要素の取得、ユーザーのイベント処理用のメソッドなどを提供
React
- UIフレームワーク
コンポーネントテスト
環境構築
コンポーネントテスト用の依存関係を追加
- @testing-library/react
- Reactコンポーネントをテストするためのライブラリ
- @testing-library/user-event
- @testing-library/reactには含まれていないユーザーのクリックイベント(click)や文字入力イベント(type)などを提供するライブラリ。組み込みのfireEventよりもよりユーザーの操作に近い記述が可能
- jsdom
- 本来ブラウザでしかDOMを使用できないが、サーバーサイドなどで仮想的にDOMツリーを生成するためのライブラリ
- @testing-library/jest-dom
- 拡張マッチャー(toBeInTheDocument、toBeVisible、toHaveClassなど)を使用する場合に必要なライブラリ
npm install -D @testing-library/react @testing-library/user-event jsdom @testing-library/jest-dom
setupファイル追加
これにより、各テストファイルでimportする必要がなくなり、かつtesting-libraryの拡張マッチャー(toBeInTheDocument
など)を使用できるようになる。これは、vueであっても、reactであってもTypeScript環境下で拡張マッチャーを使用したい場合は必要。
GitHub - testing-library/jest-dom: :owl: Custom jest matchers to test the state of the DOM
tests/setup.ts
import "@testing-library/jest-dom";
Viteの設定ファイル(vite.config.ts)調整
Domを単体テストの中で扱えるように、environmentにjsdomを追加
/// <reference types="vitest" />
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom", // here
},
});
setupファイルの指定
上記で作成したsetupファイルが全てのテストファイルで実行されるように指定
/// <reference types="vitest" />
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./tests/setup.ts"], // here
},
}
tsconfigにtsxを追加
{
"compilerOptions": {
・・・
},
"include": ["src", "tests/**/*.ts", "tests/**/*.tsx"], // here
・・・
}
コンポーネントの実装
対象のコンポーネント
以下3種類についてコンポーネントとテストをそれぞれ作成する。
- inputコンポーネントを作成し、文字入力イベントを検証する
- Formコンポーネントを作成し、文字入力とボタンのクリックイベントを検証する
- TodoListコンポーネントを作成し、クリックイベントとAPI通信を検証する
inputコンポーネント
labelやid、valueとvalueを変更するための関数onChangeなどをpropsとして受け取る。inputに対して入力されるとonChangeが発火する
src/components/TextInput.tsx
interface TextInputProps {
label: string;
id: string;
name?: string;
value: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}
const TextInput: React.FC<TextInputProps> = ({
label,
id,
name,
value,
onChange,
}) => {
return (
<div className="mb-4">
<label
className="block text-gray-700 text-sm font-bold mb-1"
htmlFor={id}
>
{label}
</label>
<input
name={name}
type="text"
id={id}
className="w-full px-3 py-2 border rounded-lg border-gray-300 focus:outline-none focus:ring focus:border-blue-300"
value={value}
onChange={onChange}
/>
</div>
);
};
export default TextInput;
Formコンポーネント
src/components/Form.tsx
import { FormEvent, useState } from "react";
import TextInput from "@/components/TextInput";
interface Props {
handleSubmit: (event: FormEvent<HTMLFormElement>) => void;
}
export const Form = ({ handleSubmit }: Props) => {
const [searchValue, setSearchValue] = useState<string>("");
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(event.target.value);
};
return (
<form onSubmit={handleSubmit} data-testid="form">
{/* 実際はcsrf_tokenが必要 */}
<TextInput
label=""
id="search-input"
name="q"
value={searchValue}
onChange={handleInputChange}
/>
<button
type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded-r-md hover:bg-blue-600 focus:ring focus:ring-blue-200 focus:outline-none"
>
送信する
</button>
</form>
);
};
TodoListコンポーネント
依存関係の追加
axiosをインストール
npm install axios
src/components/TodoList.tsx
import { useState } from "react";
import axios from "axios";
type Todo = {
id: number;
title: string;
};
const TodoList = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const fetchTodos = async () => {
try {
const response = await axios.get(
"<https://jsonplaceholder.typicode.com/todos>"
);
console.log(response.data);
setTodos(response.data);
} catch (error) {
// デモなので割愛
console.log(error);
}
};
return (
<div>
<button onClick={fetchTodos}>記事取得</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
);
};
export default TodoList;
画面上ではこのようなかんじ
テスト作成
inputコンポーネントのテスト
- renderメソッドによりTextInputコンポーネントをレンダリングする
- renderメソッドの返り値からDomから要素を取得するための各種メソッドが使える
- ユーザーの使い方に近いほうがいいので基本的にはgetByRoleを、エスケープハッチとしてgetByTestIdなどを使う
- inputタグのroleはtextboxなのでgetByRoleでinput要素を取得できる
- input要素に対して任意の文字列を入力する
- インタラクティブな操作は非同期処理なのでasync awaitにする必要があるので注意
- 最後に、期待する文字列がinput要素のvalueとして保持されているかどうかと、モック関数の呼び出し回数などをアサートする
tests/components/TextInput.test.tsx
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import TextInput from "@/components/TextInput";
test("TextInputコンポーネントのテスト", async () => {
const mockOnChange = vi.fn(); // propsとして渡すonChangeをモックに変える
const { getByLabelText, getByRole } = render(
<TextInput label="氏名" id="name-input" value="" onChange={mockOnChange} />
);
// labelが正しく表示されているか
const nameLabel = getByLabelText("氏名");
expect(nameLabel).toBeInTheDocument();
// input要素を取得し、値を入力
const input = getByRole("textbox");
await userEvent.type(input, "大阪 太郎");
// memo: onChangeをモックにしているので、valueは変更されない。これを保証するのはこのコンポーネントを使う側のテスト
expect(input).toHaveValue("");
// モック関数が正しく呼び出されたか
expect(mockOnChange).toHaveBeenCalledTimes(5); // '大阪 太郎' の各文字がイベントとしてトリガーされるため 5回呼び出される
});;
Formコンポーネントのテスト
tests/components/Form.test.tsx
テスト
- renderメソッドによりFormコンポーネントをレンダリングする
- renderメソッドの返り値からDomから要素を取得するための各種メソッドが使える
- inputタグへの文字入力はすでに説明済みなので割愛
- 送信ボタンの要素を取得しクリックイベントを発火させる
- 最後に、期待する文字列が画面上に表示されているかアサートする
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Form } from "@/components/Form";
test("Formコンポーネントのテスト", async () => {
const mockOnSubmit = vi.fn();
const { getByLabelText, getByTestId, getByRole } = render(
<Form handleSubmit={mockOnSubmit} />
);
// labelはなし
const label = getByLabelText("");
expect(label).toBeInTheDocument();
expect(label).toHaveTextContent("");
const form = getByTestId("form");
// input要素を取得し、値を入力
const input = getByRole("textbox");
await userEvent.type(input, "React");
expect(form).toHaveFormValues({
q: "React",
});
// button
const button = getByRole("button");
await userEvent.click(button);
// モック関数が正しく呼び出されたか
expect(mockOnSubmit).toHaveBeenCalledTimes(1);
// memo: 引数の正しさは検証しない(formオブジェクトそのものなので)
});
TodoListコンポーネントのテスト
- vitestのvi.spyOnによりaxiosのgetメソッドをモックにし、返り値を固定化する
- jsonplaceholderよりtodoリストのデータを取得する処理をモックに置き換える
- renderメソッドによりTodoListコンポーネントをレンダリングする
- renderメソッドの返り値からDomから要素を取得するための各種メソッドが使える
- TODO取得ボタンの要素を取得しクリックイベントを発火させる
- 最後にアサート
- モック関数が期待する引数を与えられて呼び出されたか。呼び出し回数は期待する回数か
- Todoのタイトルが画面上に表示され、その数は期待する数か
tests/components/TodoList.test.tsx
import { describe, it, expect, vi } from "vitest";
import user from "@testing-library/user-event";
import axios from "axios";
import { render } from "@testing-library/react";
import TodoList from "../../src/components/TodoList";
describe("TodoList", () => {
type Todo = {
id: number;
title: string;
};
it("送信ボタンを押すとTODOリストの一覧が表示", async () => {
const mockTodos: Todo[] = [
{ id: 1, title: "title1-1" },
{ id: 2, title: "title1-2" },
{ id: 1, title: "title2-1" },
{ id: 1, title: "title3-1" },
{ id: 2, title: "title1-2" },
];
const axiosGetMock = vi
.spyOn(axios, "get")
.mockResolvedValue({ data: mockTodos });
const { getByRole } = render(<TodoList />);
const submitButton = getByRole("button");
await user.click(submitButton);
expect(axios.get).toHaveBeenCalledWith(
"<https://jsonplaceholder.typicode.com/todos>"
);
expect(axiosGetMock).toHaveBeenCalledOnce();
expect(getByRole("list").childElementCount).toBe(5);
});
});
実行
npm run test
全てのテストが通っていればOKです。
カスタムHooksのテスト
カスタムHookのテストには、rendeHookメソッドを使用する
カスタムHooksの実装
テスト対象のカスタムHooks。カウンターをプラスしたりマイナスしたりする
src/hooks/counter.ts
import { useState } from "react";
export const useCounter = () => {
const [counter, setCounter] = useState<number>(0);
const inc = () => {
setCounter((prev) => prev + 1);
};
const dec = () => {
setCounter((prev) => prev - 1);
};
return {
counter,
inc,
dec,
};
};
テスト作成
- renderHookメソッドの引数に、コールバック関数の形でテスト対象のカスタムHookを指定する
- cleanup()はレンダリングされたフックをアンマウントする関数。カスタムhooks内で副作用がある場合は、テスト毎にcleanupした上でテストをする
- ステートの更新関数を実行したい時は、act関数の中で実行する必要がある
tests/hooks/counter.test.tsx
import { act, cleanup, renderHook } from "@testing-library/react";
import { useCounter } from "@/src/hooks/counter";
describe("カスタムHooks: useCounter", () => {
beforeEach(() => {
cleanup();
});
test("カウンターをインクリメント、デクリメントできる", () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.inc();
result.current.inc();
result.current.inc();
result.current.dec();
});
expect(result.current.counter).toBe(2);
});
});
実行
npm run test
全てのテストが通っていればOK。
まとめ
いかがでしたでしょうか。本記事では、viteベースのvitestででtesting-libraryを用いてreactコンポーネントの実装と単体・結合テスト、カスタムHooksの環境構築、テストの作成などについて説明しました。コンポーネントテストを行うために必要な依存関係や設定が少し複雑なのと、ネット上にはまだそれほど充実した記事はないので、ぜひ参考にして構築してみてください。