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

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

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

GitHubリポジトリは[こちら](https://github.com/asami-okina/todo-list/tree/main/todo-list-next-and-react-hook-form)です。
# Playwrightとは
Playwrightは、Microsoftが開発したモダンなWeb測試自動化フレームワークです。複数のブラウザ（Chromium、Firefox、WebKit）をサポートし、高速で信頼性の高いテストを可能にします。自動待機機能や強力なセレクタ、ネットワークインターセプトなどの機能を備えており、E2Eテストの作成と実行を効率化します。

# 1. Playwrightのインストール
まず、Playwrightをプロジェクトにインストールします。

```bash
yarn create playwright
```

インストール時の質問には以下のように答えてください：
- TypeScriptを使用するか？ → Yes
- テストフォルダの名前 → tests（またはお好みの名前）
- GitHub Actionsワークフローを追加するか？ → No
- Playwrightブラウザをインストールするか？ → Yes

# 2. 設定ファイルの修正
Playwrightを使用するために、いくつかの設定ファイルを修正する必要があります。

```json:package.json
{
  "name": "todo-list-next-and-react-hook-form",
  "version": "0.1.0",
  "private": true,
  "type": "module", // 追加
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@hookform/resolvers": "^3.9.0",
    "@prisma/client": "^5.17.0",
    "next": "14.2.5",
    "react": "^18",
    "react-dom": "^18",
    "react-hook-form": "^7.52.2",
    "zod": "^3.23.8",
    "@t3-oss/env-nextjs": "^0.7.1" // 追加
  },
  "devDependencies": {
    "@playwright/test": "^1.47.0", // 追加
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "eslint": "^8",
    "eslint-config-next": "14.2.5",
    "postcss": "^8",
    "prisma": "^5.17.0",
    "tailwindcss": "^3.4.1",
    "typescript": "^5"
  }
}
```

```json:tsconfig.json
{
  "compilerOptions": {
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "tests/todo.test.ts"], // "tests/todo.test.ts"の追加
  "exclude": ["node_modules"]
}
```

# 3. テストの実装
Playwrightを使用して効果的なテストを書くために、テストコードを構造化し、再利用可能なコンポーネントを作成します。

## ディレクトリ構造
```txt
tests/
├── src/
│   ├── components/
│   │   ├── button.ts
│   │   ├── input.ts
│   │   └── text.ts
│   ├── features/
│   │   └── todo.ts
│   ├── pages/
│   │   └── todo.ts
│   └── system/
│       └── database.ts
└── todo.test.ts
```
## コンポーネントの抽象化
各UIコンポーネントを抽象化することで、再利用性とメンテナンス性が向上します。
例: 
```ts:tests/src/components/button.ts
import { Locator, Page, expect } from "@playwright/test";

export class Button {
  private readonly scope: Page | Locator;
  private readonly label: string;

  constructor({ scope, label }: { scope: Page | Locator; label: string }) {
    this.scope = scope;
    this.label = label;
  }

  // 要素
  protected get button() {
    return this.scope.getByRole("button", { name: this.label, exact: true });
  }

  // 動作
  async クリックする(): Promise<void> {
    await this.button.click();
  }

  // 状態
  async あること(): Promise<void> {
    await expect(this.button).toBeVisible();
  }
}
```
同様に、InputやTextコンポーネントも抽象化します。

## ページオブジェクトモデル
ページの構造と操作を抽象化することで、テストの可読性と保守性が向上します。
例:

```ts:tests/src/pages/todo.ts
import { Page } from "@playwright/test";
import { Input } from "../components/input.js";
import { Button } from "../components/button.js";
import { Text } from "../components/text.js";

export class Todoページ {
  private readonly page: Page;

  constructor(page: Page) {
    this.page = page;
  }

  async 開く(): Promise<void> {
    await this.page.goto("http://localhost:3001");
  }

  get todoフォーム(): Input {
    return new Input({
      scope: this.page,
      label: "New todo",
    });
  }

  get 追加ボタン(): Button {
    return new Button({
      scope: this.page,
      label: "Add",
    });
  }

  // ... 
}
```

## 機能テスト
ビジネスロジックに基づいたテストを記述することで、アプリケーションの動作を明確に表現できます。

例: 

```ts:tests/src/features/todo.ts
import { Page } from "@playwright/test";
import { Todoページ } from "../pages/todo";

export class Todo機能 {
  private readonly Todoページ: Todoページ;

  constructor(private readonly page: Page) {
    this.Todoページ = new Todoページ(page);
  }

  async 新たなTodoを追加する(title: string) {
    await this.Todoページ.開く();
    await this.Todoページ.todoフォーム.入力する(title);
    await this.Todoページ.追加ボタン.クリックする();
    await this.Todoページ.追加されたTodoリスト.次の値を含むこと(title);
  }

  async 既存のTodoを削除する() {
    await this.Todoページ.開く();
    await this.Todoページ.削除ボタン.クリックする();
    await this.Todoページ.追加されたTodoリスト.ないこと();
  }
}
```

## メインのテストファイル
最終的に、これらの抽象化されたコンポーネントと機能を使用してテストを記述します。

例: 

```ts:tests/todo.test.ts
import { test } from "@playwright/test";
import { resetDatabase } from "./src/system/database.js";
import { Todo機能 } from "./src/features/todo";

test.beforeEach(async () => {
  await resetDatabase();
});

test.describe("Todoリスト", () => {
  test("新たなTODOを追加できる", async ({ page }) => {
    await new Todo機能(page).新たなTodoを追加する("ご飯を食べる");
  });

  test("TODOを削除できる", async ({ page }) => {
    await new Todo機能(page).新たなTodoを追加する("ご飯を食べる");
    await new Todo機能(page).既存のTodoを削除する();
  });
});
```

この構造化されたアプローチの利点:

再利用性: コンポーネントやページオブジェクトは複数のテストで再利用できます。
保守性: UIの変更があった場合、影響を受ける箇所が限定されます。
可読性: テストコードがより宣言的になり、ビジネスロジックが明確になります。
拡張性: 新しいテストケースの追加が容易になります。

# 4. テストの実行
テストを実行する前に、アプリケーションが起動していることを確認してください。別のターミナルで以下のコマンドを実行します：

```bash
yarn dev
```

その後、以下のコマンドでテストを実行します：

```bash
yarn playwright test
```

# 5. テスト結果の確認
テストが完了すると、コンソールに結果が表示されます。また、詳細なレポートを見るには以下のコマンドを使用します：

```bash
yarn playwright show-report
```
# まとめ
この記事では、Playwrightを使用してNext.jsアプリケーションにE2Eテストを追加する方法を学びました。主な学びポイントは以下の通りです：

1. Playwrightの基本的な使用方法
2. テストコードの構造化と再利用可能なコンポーネントの作成
3. ページオブジェクトモデルの利用
4. 機能テストの実装

この構造化されたアプローチには以下の利点があります：

- 再利用性: コンポーネントやページオブジェクトは複数のテストで再利用できます。
- 保守性: UIの変更があった場合、影響を受ける箇所が限定されます。
- 可読性: テストコードがより宣言的になり、ビジネスロジックが明確になります。
- 拡張性: 新しいテストケースの追加が容易になります。

# 学んだこと
## pageオブジェクト
pageオブジェクトは、ブラウザ内の単一のタブまたはウィンドウを表す。
テストの中で最も頻繁に使用するオブジェクトの1つ。

主な機能：
### 1. ナビゲーション：Webページへの移動
```ts
await page.goto('https://example.com');
```

### 2. 要素の操作：クリック、入力など
```ts
await page.click('button');
await page.fill('input[name="username"]', 'user');
```

### 3. 情報の取得：ページのタイトル、URL、コンテンツの取得
```ts
const title = await page.title();
const url = await page.url();
cont content = await page.content();
```

## locatorオブジェクト
locatorオブジェクトは、ページ上の要素を特定する方法を提供する。
page.locator()メソッドを使用して作成する。

主な特徴：
### 1. 自動待機
要素が利用可能になるまで自動的に待機する。

### 2. 複数の要素
複数の要素にマッチする場合、全ての要素に対して操作を行う。

### 3. チェーン可能
複数のlocatorを組み合わせて、より具体的な要素を特定できる。

使用例：
```ts
// テキストで要素を特定
const addButton = page.locator('text=Add');

// CSSセレクタで特定
const todoItem = page.locator('.todo-item');

// 複数の条件を組み合わせる
const completedTodo = page.locator('.todo-item').filter({hasText: 'Completed'});

// 操作の実行
await addButton.click();
await todoItem.first().check();
await expect(completedTodo).toBeVisible();
```