Next.js(App Router)で作ったTODOアプリにVitestでテストを追加しました

# 背景
前回の記事で、[Next.js(App Router)、Zod、Prisma、React Hook Formを使用してTODOリストアプリケーションを作成](https://zenn.dev/asamin/articles/2025b081d1cd20)しました。

今回は、このアプリケーションにvitestを使用してテストを追加する方法を紹介します。

GitHubリポジトリは[こちら](https://github.com/asami-okina/todo-list/tree/main/todo-list-next-and-react-hook-form)です。

# Vitestとは
vitestは、Viteベースの高速なJavaScriptテストフレームワークです。Jest互換のAPIを持ち、TypeScriptのサポートも優れています。

# 1. 必要なパッケージのインストール
```bash
yarn add -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom
yarn add testcontainers @testcontainers/postgresql
```

# 2. 設定ファイル
## vite.config.tsの作成
```ts:vite.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./vitest.setup.ts"],
    include: ["src/**/*.test.ts"],
  },
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
});
```

## tsconfig.jsonの更新

```json:tsconfig.json
{
  "compilerOptions": {
    "types": ["node", "vitest/globals", "testcontainers"]
  },
  "include": [
    "tests/**/*.ts"
  ]
}
```

# 3. テスト用データベースのセットアップ
テスト用のデータベースをセットアップするために、testcontainersを使用します。これにより、テストごとに隔離された環境を簡単に作成できます。また、setTestPrisma関数を使用して、マイグレーション済みのPrismaインスタンスを維持します。

## testcontainersを使ったデータベースセットアップ
vitest.setup.tsファイルを作成し、以下のコードを追加します：

```ts:vitest.setup.ts
import "@testing-library/jest-dom";
import { beforeAll, afterAll } from "vitest";
import { PrismaClient } from "@prisma/client";
import { execSync } from "child_process";
import {
  PostgreSqlContainer,
  StartedPostgreSqlContainer,
} from "@testcontainers/postgresql";
import { setTestPrisma } from "./prisma";

let container: StartedPostgreSqlContainer;
let prisma: PrismaClient;

export async function setupTestDBContainer() {
  // testcontainersを使用してPostgreSQLコンテナを起動
  container = await new PostgreSqlContainer("public.ecr.aws/docker/library/postgres:16.1-alpine")
    .withDatabase("todo_db")
    .withUsername("todouser")
    .withPassword("todopassword")
    .withReuse()
    .start();

  const port = container.getMappedPort(5432);
  
  // データベースURLを環境変数に設定
  process.env["DATABASE_URL"] = `postgresql://${container.getUsername()}:${container.getPassword()}@${container.getHost()}:${port}/todo_db?schema=public`;

  // Prismaマイグレーションを実行
  execSync("npx prisma migrate deploy", { stdio: "inherit" });

  // 新しいPrismaインスタンスを作成
  prisma = new PrismaClient({
    datasources: {
      db: {
        url: process.env.DATABASE_URL,
      },
    },
  });

  // マイグレーション済みのPrismaインスタンスをセット
  setTestPrisma(prisma);
}

beforeAll(async () => {
  console.log("Start Global setup");
  await setupTestDBContainer();
  console.log("End Global setup");
});

afterAll(async () => {
  console.log("Start Global teardown");
  await prisma.$disconnect();
  await container.stop();
  console.log("End Global teardown");
});
```

## setTestPrismaの実装
prisma.tsファイルに以下のコードを追加して、setTestPrisma関数を実装します：

```ts:prisma.ts
import { PrismaClient } from "@prisma/client";

let prisma: PrismaClient;

export function getPrismaClient(): PrismaClient {
  if (!prisma) {
    prisma = new PrismaClient();
  }
  return prisma;
}

export function setTestPrisma(newPrisma: PrismaClient) {
  prisma = newPrisma;
}
```

## なぜこのアプローチが重要か
### 1. 隔離された環境
testcontainersを使用することで、各テスト実行時に独立したデータベース環境を確保できます。これにより、テスト間の干渉を防ぎ、より信頼性の高いテスト結果を得ることができます。

### 2. 再現性
コンテナ化されたデータベースを使用することで、ローカル環境やCI/CD環境で一貫したテスト環境を維持できます。

### 3. マイグレーション済みPrismaインスタンスの維持
setTestPrisma関数を使用することで、マイグレーション後のPrismaインスタンスを全てのテストで共有できます。これにより、各テストでデータベーススキーマが正確に反映されていることを保証できます。

### 4. パフォーマンス
テスト実行ごとにデータベースをセットアップするのではなく、マイグレーション済みのインスタンスを再利用することで、テストの実行速度を向上させることができます。

このアプローチを採用することで、より堅牢で信頼性の高いテスト環境を構築でき、アプリケーションの品質向上に貢献します。

# 4. テストの実装
src/app/action.medium.test.tsファイルを作成し、TODOアクションのテストを実装します。

```ts:src/app/action.medium.test.ts
import { describe, expect, test } from "vitest";
import { addTodo, deleteTodo, toggleTodo } from "./actions";

describe("addTodo", () => {
  test("addTodoが正常に動作すること", async () => {
    const newTodo = { title: "New todo" };
    await expect(addTodo(newTodo)).resolves.toEqual({
      id: 1,
      title: "New todo",
    });
  });

  test("空のタイトルでは、addTodoが失敗すること", async () => {
    const newTodo = { title: "" };
    await expect(addTodo(newTodo)).rejects.toThrow();
  });
});

describe("toggleTodo", () => {
  test("toggleTodoが正常に動作すること", async () => {
    const newTodo = { title: "New todo" };
    const { id } = await addTodo(newTodo);
    await expect(deleteTodo(id)).resolves.toBeUndefined();
  });

  test("存在しないidでは、deleteTodoが失敗すること", async () => {
    await expect(deleteTodo(100)).rejects.toThrow();
  });
});
```

# 5. テストの実行
テストを実行するには、以下のコマンドを使用します：

```bash
yarn vitest
```

# まとめ
この記事では、Next.js(App Router)で作成したTODOアプリにvitestを使用してテストを追加する方法を紹介しました。主な学びポイントは以下の通りです：

- vitestの基本的な設定方法
- テスト用データベースのセットアップ
- サーバーアクションのテスト実装

このアプローチを採用することで、アプリケーションの信頼性を向上させ、バグの早期発見に役立ちます。