Reactコンポーネントのテストを書こう
このチュートリアルでは、Vitestのコンポーネントテスト(component testing)機能を使って、Reactコンポーネントのテストを書くことを学びます。
本章で学べること
本章では、簡単なコンポーネントのテストを書くことを目標に、具体的には次のことをやっていきます。
- Vitestを使ったReactコンポーネントテストのやり方
- コンポーネントテストを書くときの考え方
- Vitestを使ったスナップショットテストのやり方
- 単体テストとコンポーネントテストを共存させる方法
本章の目的はコンポーネントテストを完全に理解することではありません。むしろ、それがどういったものなのか、その雰囲気を実際に体験することに主眼を置いています。そのため、内容はかなり最低限のものとなりますが、少しの時間でコンポーネントテストを試してみれるシンプルな内容にまとめます。ぜひ手を動かしてみてください。
Reactでコンポーネントが作れることを前提にします。Reactの基本的な使い方を知りたい方はReactでいいねボタンを作ろうをご参照ください。また、Vitestの基本的を知りたい方はVitestでテストを書こうをご参照ください。
このチュートリアルに必要なもの
このチュートリアルで必要なものは次のとおりです。
- Node.js v24以上
- npm v11以上 (Node.jsに同梱)
Node.jsの導入については、開発環境の準備をご覧ください。
プロジェクトの作成
テストに使用するプロジェクトを作成します。
shellmkdir vitest-component-test-tutorialcd vitest-component-test-tutorial
shellmkdir vitest-component-test-tutorialcd vitest-component-test-tutorial
次の内容でpackage.jsonを作成します。
package.jsonjson{"name": "vitest-component-test-tutorial","version": "1.0.0","license": "UNLICENSED","type": "module"}
package.jsonjson{"name": "vitest-component-test-tutorial","version": "1.0.0","license": "UNLICENSED","type": "module"}
ライブラリをインストールする
このチュートリアルでは、パッケージ管理ツールとしてnpmを使います。
まず、Reactをインストールします。dependenciesとして次を入れます。
shellnpm install \react \react-dom \@types/react \@types/react-dom
shellnpm install \react \react-dom \@types/react \@types/react-dom
次に、テストに必要なものをdevDependenciesとしてインストールします。
shellnpm install -D \typescript \vitest \vitest-browser-react \@vitejs/plugin-react \@vitest/browser-playwright
shellnpm install -D \typescript \vitest \vitest-browser-react \@vitejs/plugin-react \@vitest/browser-playwright
TypeScriptコンパイラーを設定する
次の内容でtsconfig.jsonを作成します。
tsconfig.jsonjson{"compilerOptions": {"target": "esnext","moduleResolution": "bundler","strict": true,"noUncheckedIndexedAccess": true,"exactOptionalPropertyTypes": true,"verbatimModuleSyntax": true,"isolatedModules": true,"skipLibCheck": true,"jsx": "react-jsx","types": ["react"]}}
tsconfig.jsonjson{"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.tstsimport 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.tstsimport 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をインストールしてください。
shellnpx playwright install chromium
shellnpx playwright install chromium
テストするコンポーネント
ここでは、簡単なボタンコンポーネントのテストを書くことを例に進めていきます。例題として、いいねボタンを作ろうのチュートリアルで作成したいいねボタンをテストしていきます。このボタンは、クリックするといいねの数が増えるものです。本チュートリアルでは、改めて「いいねボタン」を実装するので、「いいねボタンを作ろう」のチュートリアルをやっていなくても問題ありません。
テスト対象のコンポーネントを作る
まず、like-button.tsxを作成してください。
like-button.tsxtsximport { 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.tsxtsximport { 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コンポーネントを描画し、操作し、表示が期待通りに変化することを確かめます。
テストしたいことを決める
今回は、ボタンをクリックするといいねの数が増えることをテストしたいので、次のようなテストケースを考えます。
- ボタンを表示したときのカウントが
999であることを確かめる - ボタンをクリックしたらカウントが
1000になることを確かめる
コンポーネントのテストは、基本的に次の3つのことを組み合わせて実現します。
- 描画
- 操作
- 状態確認
今回のテストケースに当てはめてみると、次のようになります。
- ボタンを表示したときのカウントが
999であること- ボタンを描画する (描画)
- ボタンのカウントが
999か確かめる (状態確認)
- ボタンをクリックしたらカウントが
1000になること- ボタンを描画する (描画)
- ボタンをクリックする (操作)
- ボタンのカウントが
1000か確かめる (状態確認)
自分でコンポーネントのテストを書く際も、どのような操作と状態確認を行えばよいかを意識することでテスト作成がスムーズにできるはずです。
テストを作る
まずは、1つ目のテストケースを作っていきましょう。like-button.browser.test.tsxというファイルこに、test関数を使ってテストケースを作成します。
like-button.browser.test.tsxtsximport { test } from "vitest";test("ボタンを表示したときのカウントが999であること", async () => {// ここにテストの中身を書いていきます});
like-button.browser.test.tsxtsximport { test } from "vitest";test("ボタンを表示したときのカウントが999であること", async () => {// ここにテストの中身を書いていきます});
描画・操作・状態確認のリズムを意識しながら、順番にテストを組んでいきましょう。最初は描画です。コンポーネントの描画はvitest-browser-reactのrenderを使って、次のようにします。
like-button.browser.test.tsxtsximport { test } from "vitest";import { render } from "vitest-browser-react";import { LikeButton } from "./like-button";test("ボタンを表示したときのカウントが999であること", async () => {await render(<LikeButton />);});
like-button.browser.test.tsxtsximport { 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.tsxtsximport { 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.tsxtsximport { 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.tsxtsximport { 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.tsxtsximport { 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")と書いた場合、部分一致でもテストが通るようになるので注意してください。1999や9990などでもテストが通ってしまいます。
ここで一旦npx vitestコマンドでテストを実行し、テストが通ることを確認しましょう。
shellnpx vitest
shellnpx 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.tsxtsximport { 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.tsxtsximport { 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ステップから成ります。
- スナップショットを検証したい状態にコンポーネントを持っていく
- スナップショットに照合する
ここではボタンが描画されてまだ何も操作されていない状態、つまりボタンに999と表示されている状態についてスナップショットテストを実施することを考えます。描画されたばかりの状態を検証したいので、描画してすぐにスナップショット照合を行えばよいことになります。
では、2つ目のテストケースを修正して、スナップショットテストを実施してみましょう。描画結果のスナップショットを取るには、次のようにrenderの戻り値のcontainerをexpect関数に渡し、toMatchSnapshotメソッドを呼び出します。
like-button.browser.test.tsxtsximport { 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.tsxtsximport { 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.snapjs// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.htmlexports[`ボタンをクリックしたらカウントが1000になること 1`] = `<div><buttontype="button">1000</button></div>`;
__snapshots__/like-button.browser.test.tsx.snapjs// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.htmlexports[`ボタンをクリックしたらカウントが1000になること 1`] = `<div><buttontype="button">1000</button></div>`;
このように、スナップショットテスト用のファイルはテストケースの名前と、そのテストケースで使われるスナップショットで構成されています。
今回生成されたスナップショットは1000というテキストを持ったbuttonタグと、その親要素であるdivタグで構成されています。これは、まさにLikeButtonコンポーネントのHTMLに一致します。
このスナップショットテストは実行のたびに、LikeButtonコンポーネントを描画して、たった今作られたこのスナップショットとの違いが生まれていないかを確認してくれます。たとえば、もしも何かの手違いで、クリック時のカウントアップが機能しなくなっていたら、このスナップショットテストで検知できます。
実際にボタンをクリックしてもカウントが増えないようにインクリメントの処理を無くし、テストが失敗する様子を確認してみましょう。
like-button.tsxtsximport { 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.tsxtsximport { 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.tstsimport 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.tstsimport 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" }],},},},],},});
projectsにunitとbrowserという2つのプロジェクトを定義しています。unitプロジェクトはユニットテストの実行に、browserプロジェクトはコンポーネントテストの実行に使用します。
これで、次のようにテストのレベルを限定して実行できます。
- ユニットテストだけを実行:
npx vitest --project unit - コンポーネントテストだけを実行:
npx vitest --project browser
テストレベルを横断して実行したい場合は、これまでどおりnpx vitestと実行するだけです。