メインコンテンツまでスキップ

Reactコンポーネントのテストを書こう

このチュートリアルでは、Vitestのコンポーネントテスト(component testing)機能を使って、Reactコンポーネントのテストを書くことを学びます。

本章で学べること

本章では、簡単なコンポーネントのテストを書くことを目標に、具体的には次のことをやっていきます。

  • Vitestを使ったReactコンポーネントテストのやり方
  • コンポーネントテストを書くときの考え方
  • Vitestを使ったスナップショットテストのやり方
  • 単体テストとコンポーネントテストを共存させる方法

本章の目的はコンポーネントテストを完全に理解することではありません。むしろ、それがどういったものなのか、その雰囲気を実際に体験することに主眼を置いています。そのため、内容はかなり最低限のものとなりますが、少しの時間でコンポーネントテストを試してみれるシンプルな内容にまとめます。ぜひ手を動かしてみてください。

備考

Reactでコンポーネントが作れることを前提にします。Reactの基本的な使い方を知りたい方はReactでいいねボタンを作ろうをご参照ください。また、Vitestの基本的を知りたい方はVitestでテストを書こうをご参照ください。

このチュートリアルに必要なもの

このチュートリアルで必要なものは次のとおりです。

  • Node.js v24以上
  • npm v11以上 (Node.jsに同梱)

Node.jsの導入については、開発環境の準備をご覧ください。

プロジェクトの作成

テストに使用するプロジェクトを作成します。

shell
mkdir vitest-component-test-tutorial
cd vitest-component-test-tutorial
shell
mkdir vitest-component-test-tutorial
cd vitest-component-test-tutorial

次の内容でpackage.jsonを作成します。

package.json
json
{
"name": "vitest-component-test-tutorial",
"version": "1.0.0",
"license": "UNLICENSED",
"type": "module"
}
package.json
json
{
"name": "vitest-component-test-tutorial",
"version": "1.0.0",
"license": "UNLICENSED",
"type": "module"
}

ライブラリをインストールする

このチュートリアルでは、パッケージ管理ツールとしてnpmを使います。

まず、Reactをインストールします。dependenciesとして次を入れます。

shell
npm install \
react \
react-dom \
@types/react \
@types/react-dom
shell
npm install \
react \
react-dom \
@types/react \
@types/react-dom

次に、テストに必要なものをdevDependenciesとしてインストールします。

shell
npm install -D \
typescript \
vitest \
vitest-browser-react \
@vitejs/plugin-react \
@vitest/browser-playwright
shell
npm install -D \
typescript \
vitest \
vitest-browser-react \
@vitejs/plugin-react \
@vitest/browser-playwright

TypeScriptコンパイラーを設定する

次の内容でtsconfig.jsonを作成します。

tsconfig.json
json
{
"compilerOptions": {
"target": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"types": ["react"]
}
}
tsconfig.json
json
{
"compilerOptions": {
"target": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"types": ["react"]
}
}

Vitestを設定する

vitest.config.tsを次の内容で作成してください。

vitest.config.ts
ts
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: "chromium" }],
},
},
});
vitest.config.ts
ts
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: "chromium" }],
},
},
});

PlaywrightのChromiumをインストールする

Vitestのコンポーネントテストには実ブラウザが必要になります。次のコマンドでChromiumをインストールしてください。

shell
npx playwright install chromium
shell
npx playwright install chromium

テストするコンポーネント

ここでは、簡単なボタンコンポーネントのテストを書くことを例に進めていきます。例題として、いいねボタンを作ろうのチュートリアルで作成したいいねボタンをテストしていきます。このボタンは、クリックするといいねの数が増えるものです。本チュートリアルでは、改めて「いいねボタン」を実装するので、「いいねボタンを作ろう」のチュートリアルをやっていなくても問題ありません。

テスト対象のコンポーネントを作る

まず、like-button.tsxを作成してください。

like-button.tsx
tsx
import { useState } from "react";
export function LikeButton() {
const [count, setCount] = useState(999);
const handleClick = () => {
setCount(count + 1);
};
return (
<button onClick={handleClick} type="button">
{count}
</button>
);
}
like-button.tsx
tsx
import { useState } from "react";
export function LikeButton() {
const [count, setCount] = useState(999);
const handleClick = () => {
setCount(count + 1);
};
return (
<button onClick={handleClick} type="button">
{count}
</button>
);
}

コンポーネントテストを書く

ここからはテストの作り方とやり方に入ります。Vitestのコンポーネントテストの実行環境はブラウザです。Reactコンポーネントを描画し、操作し、表示が期待通りに変化することを確かめます。

テストしたいことを決める

今回は、ボタンをクリックするといいねの数が増えることをテストしたいので、次のようなテストケースを考えます。

  1. ボタンを表示したときのカウントが999であることを確かめる
  2. ボタンをクリックしたらカウントが1000になることを確かめる

コンポーネントのテストは、基本的に次の3つのことを組み合わせて実現します。

  • 描画
  • 操作
  • 状態確認

今回のテストケースに当てはめてみると、次のようになります。

  • ボタンを表示したときのカウントが999であること
    • ボタンを描画する (描画)
    • ボタンのカウントが999か確かめる (状態確認)
  • ボタンをクリックしたらカウントが1000になること
    • ボタンを描画する (描画)
    • ボタンをクリックする (操作)
    • ボタンのカウントが1000か確かめる (状態確認)

自分でコンポーネントのテストを書く際も、どのような操作と状態確認を行えばよいかを意識することでテスト作成がスムーズにできるはずです。

テストを作る

まずは、1つ目のテストケースを作っていきましょう。like-button.browser.test.tsxというファイルこに、test関数を使ってテストケースを作成します。

like-button.browser.test.tsx
tsx
import { test } from "vitest";
test("ボタンを表示したときのカウントが999であること", async () => {
// ここにテストの中身を書いていきます
});
like-button.browser.test.tsx
tsx
import { test } from "vitest";
test("ボタンを表示したときのカウントが999であること", async () => {
// ここにテストの中身を書いていきます
});

描画・操作・状態確認のリズムを意識しながら、順番にテストを組んでいきましょう。最初は描画です。コンポーネントの描画はvitest-browser-reactrenderを使って、次のようにします。

like-button.browser.test.tsx
tsx
import { test } from "vitest";
import { render } from "vitest-browser-react";
import { LikeButton } from "./like-button";
test("ボタンを表示したときのカウントが999であること", async () => {
await render(<LikeButton />);
});
like-button.browser.test.tsx
tsx
import { test } from "vitest";
import { render } from "vitest-browser-react";
import { LikeButton } from "./like-button";
test("ボタンを表示したときのカウントが999であること", async () => {
await render(<LikeButton />);
});

次は状態確認です。999と表示されていることを確かめます。具体的には、ボタンを取得し、そのテキストが999という文字列に等しいかのアサーションを実施します。

今回、ボタンの取得にはgetByRoleを使います。これはWAI-ARIA(アクセシビリティ向上を主目的として定められたwebの仕様)で定められたロールを引数に指定すると、そのロールを持つ要素を取得するクエリです。具体的には、次のように書けます。詳細はLocatorをご参照ください。

like-button.browser.test.tsx
tsx
import { test } from "vitest";
import { render } from "vitest-browser-react";
import { LikeButton } from "./like-button";
test("ボタンを表示したときのカウントが999であること", async () => {
const { getByRole } = await render(<LikeButton />);
const button = getByRole("button");
});
like-button.browser.test.tsx
tsx
import { test } from "vitest";
import { render } from "vitest-browser-react";
import { LikeButton } from "./like-button";
test("ボタンを表示したときのカウントが999であること", async () => {
const { getByRole } = await render(<LikeButton />);
const button = getByRole("button");
});

そして、ボタンのテキストのアサーションはtoHaveTextContentを使います。expect.elementに要素を渡し、そのままtoHaveTextContentを呼び出すと、その要素がどのようなテキストを持っているかのアサーションが行なえます。具体的には次のようになります。

like-button.browser.test.tsx
tsx
import { expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { LikeButton } from "./like-button";
test("ボタンを表示したときのカウントが999であること", async () => {
const { getByRole } = await render(<LikeButton />);
const button = getByRole("button");
await expect.element(button).toHaveTextContent(/^999$/);
});
like-button.browser.test.tsx
tsx
import { expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { LikeButton } from "./like-button";
test("ボタンを表示したときのカウントが999であること", async () => {
const { getByRole } = await render(<LikeButton />);
const button = getByRole("button");
await expect.element(button).toHaveTextContent(/^999$/);
});

ここで、toHaveTextContentの引数に正規表現を渡しています。これは、ボタンのテキストが999という文字列に完全一致することを確かめるためです。もしも、toHaveTextContent("999")と書いた場合、部分一致でもテストが通るようになるので注意してください。19999990などでもテストが通ってしまいます。

ここで一旦npx vitestコマンドでテストを実行し、テストが通ることを確認しましょう。

shell
npx vitest
shell
npx vitest

次のような出力が表示され、「PASS」と表示されていればテストが通っています。

 DEV  v4.0.16 /Users/suin/codes/github.com/yytypescript/vitest-component-test-tutorial

   chromium  like-button.browser.test.tsx (1 test) 15ms
    ボタンを表示したときのカウントが999であること 14ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  11:56:20
   Duration  1.40s (transform 0ms, setup 0ms, import 40ms, tests 15ms, environment 0ms)

 PASS  Waiting for file changes...
       press h to show help, press q to quit

さて、今度はふたつめのテストケースです。ボタンを描画したら、クリックし、値が1000になっていることを確かめます。コンポーネントの操作は、取得した要素に対してclickを呼び出すことで実現できます。

like-button.browser.test.tsx
tsx
import { expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { LikeButton } from "./like-button";
test("ボタンを表示したときのカウントが999であること", async () => {
const { getByRole } = await render(<LikeButton />);
const button = getByRole("button");
await expect.element(button).toHaveTextContent(/^999$/);
});
test("ボタンをクリックしたらカウントが1000になること", async () => {
const { getByRole } = await render(<LikeButton />);
const button = getByRole("button");
await button.click();
await expect.element(button).toHaveTextContent(/^1000$/);
});
like-button.browser.test.tsx
tsx
import { expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { LikeButton } from "./like-button";
test("ボタンを表示したときのカウントが999であること", async () => {
const { getByRole } = await render(<LikeButton />);
const button = getByRole("button");
await expect.element(button).toHaveTextContent(/^999$/);
});
test("ボタンをクリックしたらカウントが1000になること", async () => {
const { getByRole } = await render(<LikeButton />);
const button = getByRole("button");
await button.click();
await expect.element(button).toHaveTextContent(/^1000$/);
});

この状態でnpx vitestコマンドの出力を確認しましょう。テストが再実行され、2つ目のテストケースもPASSしているはずです。

 RERUN  like-button.browser.test.tsx x1 

   chromium  like-button.browser.test.tsx (2 tests) 72ms
    ボタンを表示したときのカウントが999であること 4ms
    ボタンをクリックしたらカウントが1000になること 67ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  11:47:15
   Duration  82ms

 PASS  Waiting for file changes...
       press h to show help, press q to quit

以上が、VitestのコンポーネントテストでReactコンポーネントのテストを作成する流れです。

Vitestを使ったスナップショットテストの作り方とやり方

ここからは「スナップショットテスト」と呼ばれるテスト手法について解説します。先ほどまでのテストはコンポーネントのある部分(例: テキスト)の状態を確認するものでしたが、「スナップショットテスト」はコンポーネントの全体の状態を確かめるためのテストです。より正確には、コンポーネントのHTMLをまるごと保存し、テスト実行時にコンポーネントを描画して生成したHTMLと保存しておいたHTMLが一致するかを確認します。

「スナップショットテスト」は簡単に書けます。それでいてスタイルなど含めた全体の確認ができるので、手軽なリグレッションテストとして活用できます。一方で、そうであるからこそコンポーネントを一旦作り終えるまでは機能しないテストですので、テストファースト開発には不向きです。

注意

本来、スナップショットテストの対象はコンポーネントに限られたものではありません。幅広いテストにスナップショットテストが実施できます。詳しくはVitestの公式ドキュメントをご参照ください。

それでは、スナップショットテストを実際にやってみましょう。

スナップショットテストは次の2ステップから成ります。

  1. スナップショットを検証したい状態にコンポーネントを持っていく
  2. スナップショットに照合する

ここではボタンが描画されてまだ何も操作されていない状態、つまりボタンに999と表示されている状態についてスナップショットテストを実施することを考えます。描画されたばかりの状態を検証したいので、描画してすぐにスナップショット照合を行えばよいことになります。

では、2つ目のテストケースを修正して、スナップショットテストを実施してみましょう。描画結果のスナップショットを取るには、次のようにrenderの戻り値のcontainerexpect関数に渡し、toMatchSnapshotメソッドを呼び出します。

like-button.browser.test.tsx
tsx
import { expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { LikeButton } from "./like-button";
test("ボタンを表示したときのカウントが999であること", async () => {
const { getByRole } = await render(<LikeButton />);
const button = getByRole("button");
await expect.element(button).toHaveTextContent(/^999$/);
});
test("ボタンをクリックしたらカウントが1000になること", async () => {
const { getByRole, container } = await render(<LikeButton />);
const button = getByRole("button");
await button.click();
expect(container).toMatchSnapshot();
});
like-button.browser.test.tsx
tsx
import { expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { LikeButton } from "./like-button";
test("ボタンを表示したときのカウントが999であること", async () => {
const { getByRole } = await render(<LikeButton />);
const button = getByRole("button");
await expect.element(button).toHaveTextContent(/^999$/);
});
test("ボタンをクリックしたらカウントが1000になること", async () => {
const { getByRole, container } = await render(<LikeButton />);
const button = getByRole("button");
await button.click();
expect(container).toMatchSnapshot();
});

変更を保存すると、テストが再実行されます。出力には「Snapshots 1 written」と表示されています。これは、スナップショットファイルが生成されたことを示しています。

 RERUN  like-button.browser.test.tsx x2 

   chromium  like-button.browser.test.tsx (2 tests) 34ms
    ボタンを表示したときのカウントが999であること 3ms
    ボタンをクリックしたらカウントが1000になること 30ms

  Snapshots  1 written
 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  11:58:41
   Duration  39ms

 PASS  Waiting for file changes...
       press h to show help, press q to quit

__snapshots__というディレクトリが自動で追加されているはずです。これはVitestがスナップショットテスト用のファイルを保存していくためのフォルダです。Vitestのスナップショットテストは初回実行時にスナップショットテスト用のファイルを生成し、2回目から照合を行います。

ここでスナップショットテストについてもう少しだけ知るために、生成されたスナップショットテスト用のファイルの中身を覗いてみましょう。__snapshots__ディレクトリの中に作られたlike-button.browser.test.tsx.snapは次のようになっています。

__snapshots__/like-button.browser.test.tsx.snap
js
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ボタンをクリックしたらカウントが1000になること 1`] = `
<div>
<button
type="button"
>
1000
</button>
</div>
`;
__snapshots__/like-button.browser.test.tsx.snap
js
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ボタンをクリックしたらカウントが1000になること 1`] = `
<div>
<button
type="button"
>
1000
</button>
</div>
`;

このように、スナップショットテスト用のファイルはテストケースの名前と、そのテストケースで使われるスナップショットで構成されています。

今回生成されたスナップショットは1000というテキストを持ったbuttonタグと、その親要素であるdivタグで構成されています。これは、まさにLikeButtonコンポーネントのHTMLに一致します。

このスナップショットテストは実行のたびに、LikeButtonコンポーネントを描画して、たった今作られたこのスナップショットとの違いが生まれていないかを確認してくれます。たとえば、もしも何かの手違いで、クリック時のカウントアップが機能しなくなっていたら、このスナップショットテストで検知できます。

実際にボタンをクリックしてもカウントが増えないようにインクリメントの処理を無くし、テストが失敗する様子を確認してみましょう。

like-button.tsx
tsx
import { useState } from "react";
export function LikeButton() {
const [count, setCount] = useState(999);
const handleClick = () => {
// setCount(count + 1);
// 上をコメントアウトしてください
};
return (
<button onClick={handleClick} type="button">
{count}
</button>
);
}
like-button.tsx
tsx
import { useState } from "react";
export function LikeButton() {
const [count, setCount] = useState(999);
const handleClick = () => {
// setCount(count + 1);
// 上をコメントアウトしてください
};
return (
<button onClick={handleClick} type="button">
{count}
</button>
);
}

この状態でもう一度、Vitestの実行結果を確認しましょう。すると、先ほどのスナップショットテストが実行されますが、今回はテストが通らず、描画されたコンポーネントとスナップショットの差分が表示されます。次の差分からは、スナップショットとしては1000を期待しているのに、実際には999が表示されていることが分かります。

 RERUN  like-button.tsx x1 

   chromium  like-button.browser.test.tsx (2 tests | 1 failed) 39ms
    ボタンを表示したときのカウントが999であること 4ms
   × ボタンをクリックしたらカウントが1000になること 35ms

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL   chromium  like-button.browser.test.tsx:11:1 > ボタンをクリックしたらカウントが1000になること
Error: Snapshot `ボタンをクリックしたらカウントが1000になること 1` mismatched

- Expected
+ Received

  <div>
    <button
      type="button"
    >
-     1000
+     999
    </button>
  </div>

  like-button.browser.test.tsx:15:20
     13|   const button = getByRole("button");
     14|   await button.click();
     15|   expect(container).toMatchSnapshot();
       |                    ^
     16| });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


  Snapshots  1 failed
 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)
   Start at  17:57:59
   Duration  47ms

 FAIL  Tests failed. Watching for file changes...
       press u to update snapshot, press h to show help

今回はボタンの振る舞いを変更しましたが、たとえばbuttonタグからdivタグへの変更や、buttonタグへのクラスの追加など、DOMに対する変更のほとんどをスナップショットテストで検知できます。

スナップショットテストの詳しいやり方やベストプラクティスなど、さらに詳しい情報に触れたい方はVitestの公式ドキュメントをご参照ください。

ユニットテストとコンポーネントテストを共存させる

ここまでの手順では、まずはシンプルにはじめるため、vitest.config.tsがコンポーネントテスト専用の設定になっています。

一方で実務では、コンポーネントテストだけでなく、ユニットテストや結合テストなどさまざまなレベルのテストが必要になります。ここからは、Vitestでテストを書こうの続きとして、ユニットテストとコンポーネントテストを共存させる方法を学びます。

まず、Vitestのテストプロジェクト機能を用いた設定に変更します。変更後のvitest.config.tsは次のようになります。

vitest.config.ts
ts
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
projects: [
{
test: {
name: "unit",
include: ["**/*.unit.{test,spec}.ts"],
environment: "node",
},
},
{
test: {
name: "browser",
include: ["**/*.browser.{test,spec}.{ts,tsx}"],
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: "chromium" }],
},
},
},
],
},
});
vitest.config.ts
ts
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
projects: [
{
test: {
name: "unit",
include: ["**/*.unit.{test,spec}.ts"],
environment: "node",
},
},
{
test: {
name: "browser",
include: ["**/*.browser.{test,spec}.{ts,tsx}"],
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: "chromium" }],
},
},
},
],
},
});

projectsunitbrowserという2つのプロジェクトを定義しています。unitプロジェクトはユニットテストの実行に、browserプロジェクトはコンポーネントテストの実行に使用します。

これで、次のようにテストのレベルを限定して実行できます。

  • ユニットテストだけを実行: npx vitest --project unit
  • コンポーネントテストだけを実行: npx vitest --project browser

テストレベルを横断して実行したい場合は、これまでどおりnpx vitestと実行するだけです。