Vitest(React)導入ガイド in 2024

はじめに

vitestは、2024年時点で非常に勢いのある、フロントエンドテスト用のライブラリです。

Vitest
Next generation testing framework powered by Vite

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拡張機能を追加しておくと便利です。

Vitest - Visual Studio Marketplace
Extension for Visual Studio Code - A Vite-native testing framework. It's fast!

テストの記述

簡単なテスト

まずは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);