こんにちは、かつコーチです。
前回は、React Testing Library(RTL)の基本的な使い方として、コンポーネントの描画結果を検証する方法を解説しました。
今回はいよいよ、クリックや入力といったユーザー操作を伴うコンポーネントのユニットテストを書いていきます。
フォームやカウンターのような、状態(state)が絡む実践的なコンポーネントを題材に進めていきましょう。
ユニットテストの対象を決める
何をテストし、何をテストしないか
コンポーネントのユニットテストを書き始めるとき、最初につまずきやすいのが「どこまでテストすればいいのか」という線引きです。
基本的な考え方はシンプルで、次の2点に絞ります。
- ユーザーが操作した結果、画面の表示がどう変わるか
- 特定の条件のとき、意図した要素が表示・非表示になるか
CSSの細かいスタイルや、内部的な変数の値そのものまでテストしようとすると、キリがなくなってしまいます。
「ユーザーから見て正しく動いているか」を基準にすることで、テストの範囲を適切に絞り込めます。
テスト対象のコンポーネントを用意する
今回は、シンプルなカウンターコンポーネントを例にします。
// Counter.tsx
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(0)}>リセット</button>
</div>
);
}
ボタンを押すたびにカウントが増え、「リセット」ボタンで0に戻る、というシンプルな挙動です。
user-eventでユーザー操作をシミュレートする
クリック操作のテスト
ユーザー操作をシミュレートするには、@testing-library/user-eventを使います。
// Counter.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";
test("+1ボタンを押すとカウントが増える", async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole("button", { name: "+1" });
await user.click(button);
expect(screen.getByText("カウント: 1")).toBeInTheDocument();
});
userEvent.setup()でユーザー操作用のインスタンスを作成し、user.click(button)でクリックをシミュレートします。
クリック後、screen.getByTextで「カウント: 1」という表示に変わっているかを確認しています。
複数の操作を組み合わせたテスト
「2回クリックしてからリセットする」といった、一連の操作もテストできます。
test("複数回クリック後、リセットボタンで0に戻る", async () => {
const user = userEvent.setup();
render(<Counter />);
const incrementButton = screen.getByRole("button", { name: "+1" });
const resetButton = screen.getByRole("button", { name: "リセット" });
await user.click(incrementButton);
await user.click(incrementButton);
expect(screen.getByText("カウント: 2")).toBeInTheDocument();
await user.click(resetButton);
expect(screen.getByText("カウント: 0")).toBeInTheDocument();
});
1つのテストの中で複数の操作を連続して行い、それぞれの段階で期待通りの表示になっているかを確認しています。
フォーム入力のテスト
テキスト入力を伴うコンポーネント
もう少し実践的な例として、入力フォームのテストも見ておきましょう。
// NameForm.tsx
import { useState } from "react";
export function NameForm() {
const [name, setName] = useState("");
return (
<div>
<label htmlFor="name">名前</label>
<input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<p>{name ? `こんにちは、${name}さん` : "名前を入力してください"}</p>
</div>
);
}
// NameForm.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { NameForm } from "./NameForm";
test("名前を入力すると挨拶メッセージが表示される", async () => {
const user = userEvent.setup();
render(<NameForm />);
const input = screen.getByLabelText("名前");
await user.type(input, "かつコーチ");
expect(screen.getByText("こんにちは、かつコーチさん")).toBeInTheDocument();
});
getByLabelTextで<label>と紐づいた入力欄を取得し、user.typeで実際のキー入力を再現しています。
<label>のhtmlForと<input>のidをきちんと対応させておくことが、このテストを書くための前提条件になる点にも注目してください。
つまずきやすいポイント:非同期処理の待ち忘れ
awaitを忘れてテストが不安定になる
私が実際に一度ハマったのが、user.clickやuser.typeに対するawaitの書き忘れです。
❌ Before:awaitを書き忘れる
test("+1ボタンを押すとカウントが増える(失敗しやすい例)", () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole("button", { name: "+1" });
user.click(button); // awaitを忘れている
expect(screen.getByText("カウント: 1")).toBeInTheDocument();
// ローカルではたまたま通ることもあるが、CI環境などで不安定に失敗する
});
user.clickは非同期処理として実装されているため、awaitを付けずに次の行へ進んでしまうと、クリックによる状態更新が反映される前にexpectが実行されてしまうことがあります。
自分のPCではなぜか毎回パスしていたのに、CI環境で実行するとたまに失敗する、という不安定なテストになってしまい、原因を突き止めるのに時間がかかりました。
✅ After:awaitを必ず付ける
test("+1ボタンを押すとカウントが増える", async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole("button", { name: "+1" });
await user.click(button);
expect(screen.getByText("カウント: 1")).toBeInTheDocument();
});
user-eventの操作メソッドはすべて非同期なので、テスト関数自体をasyncにし、各操作の前にawaitを付けるのを徹底することで、この不安定さを解消できます。
「クリックや入力のシミュレートには必ずawaitを付ける」——これは今でも自分に言い聞かせているルールです。
テストを書く際の心構え
実装の詳細ではなく振る舞いをテストする
最後に、これまでの内容を通じて意識してほしい考え方をまとめておきます。
「stateの変数名が正しいか」ではなく「画面の表示が正しく変わるか」をテストすることで、内部実装を変更してもテストが壊れにくくなります。
たとえばCounterコンポーネントの内部でuseStateの代わりに別のstate管理方法を使うようにリファクタリングしても、画面の振る舞いが変わらなければ、これまで書いたテストはそのまま通り続けます。
これがRTLの「ユーザー視点でテストする」という設計思想の一番のメリットです。
まとめ
この記事のポイント
- コンポーネントのテストは「ユーザーから見た振る舞い」を基準に範囲を決める
@testing-library/user-eventでクリックや入力といった操作をシミュレートできるgetByLabelTextなど、実際のUI構造に沿った要素の取得方法を使うuser.clickやuser.typeには必ずawaitを付け、非同期処理の待ち忘れを防ぐ- 実装の詳細ではなく振る舞いをテストすることで、リファクタリングに強いテストになる
次に読むべき記事
コンポーネント単位のユニットテストが書けるようになったら、次はいよいよ実際のブラウザを操作して、アプリ全体の動きを検証するE2Eテストに挑戦していきましょう。
→ 次の記事:E2Eテスト入門:Playwrightでブラウザ操作を自動化する