はじめに
vitestは、2024年時点で非常に勢いのある、フロントエンドテスト用のライブラリです。
Reactプロジェクトでの基本的なセットアップ手順をまとめました。
testing-libraryも含めて導入しています。
環境構築
viteでプロジェクトを構築します。
npm create vite@latest
選択肢は下記の通りに設定しました。
✔ Project name: … vitest-sample
✔ Select a framework: › React
✔ Select a variant: › TypeScript + SWC
コマンドで起動します。
cd vitest-sample
npm install
npm run dev
画面が出ればOKです。
Vitestの設定
必要な依存関係をインストールしておきます。
npm install -D @testing-library/jest-dom @testing-library/react @testing-library/user-event @vitest/coverage-v8 happy-dom vitest vite-tsconfig-paths
次に、設定ファイルを作成します。
touch vitest-setup.ts
内容は下記の通りです。
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
毎回cleanupするのは全体で共通にしておくと良いでしょう。
次にvite.config.tsに追記します。
/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import tsconfigPaths from "vite-tsconfig-paths";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: "happy-dom",
setupFiles: ["./vitest-setup.ts"],
globals: true,
},
});
今回は使用する環境にhappy-domを選択しました。
jsdomよりも動作が速いのが特徴。使用するAPIは少なめとのことですが、触ってみて問題になることはなさそうでした。
他にもjsdom
や、edge-runtime
といった選択肢があります。
setupFilesのところで、先に作成したvitest-setup.tsを指定します。
globals: trueは、vitestからのimport文(describe、it、expectなど)を不要にするオプションです。
tsconfig.app.jsonに追記します。
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["vitest/globals"],// 追記1
"baseUrl": "./",// 追記2
"paths": {
"@/*": ["src/*"],
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src","vitest-setup.ts"]// 追記3
}
追記1の
“types”: [“vitest/globals”]
はvitestからのimport文を不要にする設定です。
追記2の
"baseUrl": "./",
"paths": {
"@/*": ["src/*"],
},
はパスエイリアスの設定です。
追記3でvitest-setup.tsを対象に追加する必要があります。
(追記3の情報が少なくて苦労しました、tsconfig.appとtsconfig.nodeに分かれたのが最近だからでしょうか🤔)
package.jsonにテスト関連のコマンドを追加しておきます。
"scripts": {
"dev": "vite",
"test": "vitest",
"coverage": "vitest run --coverage",
"build": "tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
Vitest公式のVSCode拡張機能を追加しておくと便利です。
テストの記述
簡単なテスト
まずはdomを必要としない、1+2=3 のテストを作成します。
src/components/sum.tsに、テスト対象の関数を作成します。
export function sum(a: number, b: number) {
return a + b;
}
src/tests/sum.test.ts を作成します。
(testsディレクトリの名前は__tests__など、お好みでOK)
import { sum } from "@/components/sum";
test("adds 1 + 2 to equal 3", () => {
expect(sum(1, 2)).toBe(3);
});
このテストをnpm run testで流してみます。
成功すればOKです。
expect(sum(1, 2)).toBe(4);
にすると、
落ちることが確認できました。
testing-libraryを使用したdomのテスト
実際にReactコンポーネントをテストします。
viteのデフォルト画面をテストすることにします。
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App
テスト項目は
- Vite + Reactのタイトルが表示されていること
- 初期カウントが0であること
- ボタンをクリックすると、カウントアップすること
とします。
app.test.tsxとして、テストファイルを作成します。
import App from "@/App";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
describe("初期状態のテスト", () => {
beforeEach(() => {
render(<App />);
});
it("タイトル表示", () => {
const title = screen.getByText("Vite + React");
expect(title).toBeInTheDocument();
});
it('ボタンの初期カウントが0であること', () => {
const button = screen.getByRole("button");
expect(button).toHaveTextContent("count is 0");
});
});
describe("カウントアップのテスト", () => {
beforeEach(() => {
render(<App />);
});
it("ボタンをクリックするとカウントが1増えること", async () => {
const button = screen.getByRole("button");
const user = userEvent.setup();
await user.click(button);
expect(button).toHaveTextContent("count is 1");
});
it("ボタンを3回クリックするとカウントが3増えること", async () => {
const button = screen.getByRole("button");
const user = userEvent.setup();
await user.click(button);
await user.click(button);
await user.click(button);
expect(button).toHaveTextContent("count is 3");
});
});
実行してテストが通ればOKです。
testing-libraryを使用してdomのテストが実行できました。
カバレッジ
npm run coverage
で、カバレッジレポートが表示されます。
スナップショット
スナップショットテストは正常な動作保証がされている関数の戻り値をファイルに記録しておくことで、改修後にデグレがないか確認に役立てられるテストです。(React コンポーネントのテストとはまた違います。)
src/components/calculateTaskStats.ts (タスクの完了状態を計算する関数)
を作成します。
interface Task {
id: string;
title: string;
completed: boolean;
}
interface TaskStats {
total: number;
completed: number;
pending: number;
completionRate: number;
}
/**
* タスクのリストから統計情報を計算します。
*
* @param tasks - タスクオブジェクトの配列。それぞれのタスクはid、タイトル、完了状態を持つ。
* @returns タスクの総数、完了したタスクの数、未完了のタスクの数、
* および完了率(パーセンテージ)を含むオブジェクト。
*/
export function calculateTaskStats(tasks: Task[]): TaskStats {
const total = tasks.length;
const completed = tasks.filter((task) => task.completed).length;
const pending = total - completed;
const completionRate = total > 0 ? (completed / total) * 100 : 0;
return {
total,
completed,
pending,
completionRate,
};
}
テストファイルも作成します。
src/tests/task.test.ts
import { calculateTaskStats } from "@/components/calculateTaskStats";
test("タスクのステータスを計算", () => {
const tasks = [
{ id: "1", title: "Task 1", completed: true },
{ id: "2", title: "Task 2", completed: false },
{ id: "3", title: "Task 3", completed: true },
];
const stats = calculateTaskStats(tasks);
expect(stats).toMatchSnapshot();
});
この状態で、テストを実行します。
src/tests/__snapshots__/task.test.ts.snap
が作成されます。
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`タスクのステータスを計算 1`] = `
{
"completed": 2,
"completionRate": 66.66666666666666,
"pending": 1,
"total": 3,
}
`;
関数の出力結果がスナップショットとして記録されました!
再度実行するとスナップショットと比較が走り、成功するはずです。
calculateTaskStats関数を変更してみて、テストが落ちる点も確認すると良いでしょう。
モック
例として、ユーザープロフィールをAPIから取得し、レンダリングするコンポーネントを想定します。
src/components/UserProfile.tsx
import { useState, useEffect } from 'react';
import { fetchUserData, User } from './api';
interface UserProfileProps {
userId: number;
}
function UserProfile({ userId }: UserProfileProps) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const loadUser = async () => {
const userData = await fetchUserData(userId);
setUser(userData);
};
loadUser();
}, [userId]);
if (!user) return <div>Loading...</div>;
return (
<div>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<button onClick={() => console.log(`Contacting ${user.name}`)}>
Contact User
</button>
</div>
);
}
export default UserProfile;
API呼び出しの関数を別で作成しておきます。
src/components/api.ts
export interface User {
id: number;
name: string;
email: string;
}
export const fetchUserData = async (userId: number): Promise<User> => {
const response = await fetch(`https://api.example.com/users/${userId}`);
return response.json();
};
テストを作成します。
API呼び出し部分はモックにする必要があります。
src/tests/UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import UserProfile from '@/components/UserProfile';
import { fetchUserData } from '@/components/api';
import userEvent from '@testing-library/user-event';
// モックの型を定義
vi.mock('@/components/api', () => ({
fetchUserData: vi.fn(),
}));
describe('UserProfile', () => {
beforeEach(() => {
vi.mocked(fetchUserData).mockResolvedValue({
id: 1,
name: 'John Doe',
email: 'john@example.com'
});
});
it('プロフィールがレンダリングされること', async () => {
render(<UserProfile userId={1} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
const userName = await screen.findByText('John Doe');
expect(userName).toBeInTheDocument();
expect(screen.getByText('Email: john@example.com')).toBeInTheDocument();
expect(fetchUserData).toHaveBeenCalledWith(1);
});
it('ボタンを押すとコンソールに名前が表示されること', async () => {
const consoleSpy = vi.spyOn(console, 'log');
render(<UserProfile userId={1} />);
await screen.findByText('John Doe');
const user = userEvent.setup();
await user.click(screen.getByText('Contact User'));
expect(consoleSpy).toHaveBeenCalledWith('Contacting John Doe');
});
});
モックの関数がいくつかできたので、まとめます。
vi.mock
モジュール全体をモックする。
テストファイルの最上部で使用する。
vi.mocked
TypeScript の型ヘルパー。渡されたオブジェクトを返すだけです。
TypeScriptの型が効くようにしているだけのユーティリティ。
vi.spyOn
既存のオブジェクトのメソッドを監視(スパイ)したり、置き換えたりするために使用。
const spy = vi.spyOn(api, 'fetchUserData');
// 実装を置き換える場合はこちら
// const spy = vi.spyOn(api, 'fetchUserData').mockResolvedValue({ id: 1, name: 'John' });
expect(spy).toHaveBeenCalledWith(1);