T-CREATOR

Playwright テストデータ設計のベストプラクティス:分離・再現・クリーニング戦略

Playwright テストデータ設計のベストプラクティス:分離・再現・クリーニング戦略

Web アプリケーションのテストにおいて、テストデータの設計は成功を左右する重要な要素です。適切に管理されたテストデータは、テストの信頼性を高め、メンテナンスコストを削減し、チーム全体の開発効率を向上させるでしょう。しかし、実際の開発現場では「テストが他のテストに影響される」「本番データを誤って操作してしまった」「テストが不安定で再現できない」といった課題に直面することも少なくありません。

本記事では、Playwright を使ったテストデータ設計における分離・再現・クリーニングという 3 つの柱に焦点を当て、実践的なベストプラクティスをご紹介します。これらの戦略を理解し実装することで、安定した高品質なテスト環境を構築できるようになるでしょう。

背景

テストデータ管理の重要性

E2E テストにおいて、テストデータは「テストの入力」であり「期待される状態」を定義する基盤となります。適切なテストデータ設計がなければ、以下のような問題が発生しやすくなるのです。

  • テスト間でデータが競合し、実行順序によって結果が変わる
  • データの初期状態が不明瞭で、テスト失敗の原因特定が困難になる
  • 本番環境やステージング環境のデータを誤って変更してしまうリスク
  • テストデータのクリーンアップが不十分で、データベースが肥大化する

Playwright におけるテストデータの役割

Playwright は、ブラウザを操作してユーザーの動作を再現する E2E テストフレームワークです。テストコードは UI 操作に集中できますが、その背後にあるデータの管理は開発者の責任となります。

テストデータ設計の全体像を以下の図で示します。

mermaidflowchart TB
  TestCode["Playwright<br/>テストコード"] -->|利用| TestData["テストデータ"]
  TestData -->|分離戦略| Strategy1["環境分離<br/>データ分離"]
  TestData -->|再現戦略| Strategy2["固定データ<br/>Factory パターン"]
  TestData -->|クリーニング| Strategy3["自動削除<br/>リセット処理"]

  Strategy1 --> Stable["安定した<br/>テスト実行"]
  Strategy2 --> Stable
  Strategy3 --> Stable

この図が示すように、テストデータは「分離」「再現」「クリーニング」という 3 つの戦略で管理されます。それぞれの戦略が相互に補完し合うことで、安定したテスト環境が実現できるのです。

課題

テストデータ設計における典型的な問題

実際の開発現場では、以下のような課題に直面することがあります。

1. データの競合による不安定なテスト

複数のテストが同じデータを参照・更新すると、実行順序によって結果が変わってしまいます。

typescript// ❌ 悪い例:共有データへの依存
test('ユーザー情報を更新', async ({ page }) => {
  // user@example.com を使用(他のテストも同じメールアドレスを使用)
  await page.fill('#email', 'user@example.com');
  await page.click('#update');
});

このコードでは、user@example.com という固定のメールアドレスを使用しています。他のテストでも同じメールアドレスが使われていると、テストが並行実行された際にデータが競合し、予期しない結果を招くでしょう。

2. 環境間のデータ不整合

開発環境、ステージング環境、本番環境でデータ構造が異なると、テストの移植性が低下します。

typescript// ❌ 悪い例:環境依存のデータ参照
test('商品を検索', async ({ page }) => {
  // 開発環境にしか存在しない ID を直接参照
  await page.goto('/products/12345');
});

この実装では、特定の ID(12345)に依存しているため、他の環境では動作しません。環境ごとにテストコードを書き換える必要が生じてしまいます。

3. テスト後のデータ残留

テスト実行後にデータが削除されないと、次回のテスト実行に影響を与えたり、ストレージが圧迫されたりします。

typescript// ❌ 悪い例:クリーンアップなし
test('新規アカウント作成', async ({ page }) => {
  await page.fill('#username', 'testuser');
  await page.fill('#password', 'password123');
  await page.click('#signup');
  // データが残ったまま終了
});

このテストは新規ユーザーを作成しますが、テスト後にそのユーザーを削除していません。次回の実行時に「ユーザー名が既に存在する」というエラーが発生する可能性があります。

課題の影響範囲

これらの課題は、以下のような状態遷移図で表現できます。

mermaidstateDiagram-v2
  [*] --> TestStart: テスト開始
  TestStart --> DataConflict: データ競合発生
  TestStart --> EnvMismatch: 環境不整合
  TestStart --> NoCleanup: クリーンアップなし

  DataConflict --> TestFail: テスト失敗
  EnvMismatch --> TestFail: テスト失敗
  NoCleanup --> DataAccumulate: データ蓄積

  DataAccumulate --> Performance: パフォーマンス低下
  TestFail --> Debug: デバッグ作業
  Debug --> TimeWaste: 時間浪費
  Performance --> TimeWaste

  TimeWaste --> [*]

この図から、テストデータの問題が最終的に時間の浪費という共通の結果に至ることがわかります。適切な戦略を導入することで、これらの問題を未然に防げるでしょう。

解決策

1. 分離戦略:環境とデータの独立性を確保

環境分離の実装

環境変数を使って、テスト環境を本番環境から完全に分離します。

typescript// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL:
      process.env.TEST_BASE_URL || 'http://localhost:3000',
  },
});

この設定により、環境変数 TEST_BASE_URL でテスト対象の URL を切り替えられます。本番環境の URL をハードコードすることなく、安全にテストを実行できるでしょう。

環境ごとの設定ファイルを用意することで、より柔軟な管理が可能です。

typescript// config/test.config.ts
export const testConfig = {
  apiBaseUrl:
    process.env.API_BASE_URL || 'http://localhost:8080/api',
  dbConnection: process.env.TEST_DB_URL,
  testDataPrefix: 'test_',
};

この設定ファイルは、API のベース URL やデータベース接続情報、テストデータの接頭辞を一元管理します。接頭辞を付けることで、テストデータと本番データを明確に区別できますね。

データ分離の実装

各テストで独自のデータセットを使用し、他のテストへの影響を排除します。

typescript// fixtures/user.fixture.ts
import { test as base } from '@playwright/test';
import { v4 as uuidv4 } from 'uuid';

type UserFixture = {
  uniqueUser: {
    email: string;
    username: string;
    password: string;
  };
};

このコードでは、fixture として一意なユーザー情報を生成する型を定義しています。UUID を使うことで、テストごとに完全にユニークなデータを確保できるのです。

次に、fixture の実装を追加します。

typescriptexport const test = base.extend<UserFixture>({
  uniqueUser: async ({}, use) => {
    const uniqueId = uuidv4();
    const user = {
      email: `test-${uniqueId}@example.com`,
      username: `testuser-${uniqueId}`,
      password: 'TestPass123!',
    };
    await use(user);
  },
});

この fixture は、テストが実行されるたびに新しい UUID を生成し、それをメールアドレスとユーザー名に組み込みます。これにより、並行実行されるテスト間でデータが競合することはありません。

実際のテストでは、以下のように使用します。

typescript// tests/user.spec.ts
import { test } from '../fixtures/user.fixture';
import { expect } from '@playwright/test';

test('新規ユーザー登録', async ({ page, uniqueUser }) => {
  await page.goto('/signup');
  await page.fill('#email', uniqueUser.email);
  await page.fill('#username', uniqueUser.username);
  await page.fill('#password', uniqueUser.password);
  await page.click('#submit');

  await expect(
    page.locator('.success-message')
  ).toBeVisible();
});

このテストは uniqueUser fixture を使用することで、毎回異なるユーザー情報でテストを実行します。他のテストが同時に実行されていても、データの競合は発生しないでしょう。

2. 再現戦略:一貫性のあるテストデータの生成

Factory パターンの実装

テストデータの生成ロジックを Factory パターンで集約し、一貫性を保ちます。

typescript// factories/user.factory.ts
import { faker } from '@faker-js/faker';

export class UserFactory {
  static create(overrides?: Partial<User>): User {
    return {
      id: faker.string.uuid(),
      email: faker.internet.email(),
      username: faker.internet.userName(),
      password: 'DefaultPass123!',
      createdAt: new Date(),
      ...overrides,
    };
  }
}

UserFactory クラスは、Faker ライブラリを使ってランダムなユーザーデータを生成します。overrides パラメータにより、必要な項目だけをカスタマイズできる柔軟性も備えています。

複数のユーザーを一括生成する機能も追加しましょう。

typescriptexport class UserFactory {
  // ... create メソッド

  static createMany(
    count: number,
    overrides?: Partial<User>
  ): User[] {
    return Array.from({ length: count }, () =>
      this.create(overrides)
    );
  }

  static createAdmin(overrides?: Partial<User>): User {
    return this.create({
      role: 'admin',
      permissions: ['read', 'write', 'delete'],
      ...overrides,
    });
  }
}

createMany メソッドは指定された数のユーザーを生成し、createAdmin メソッドは管理者権限を持つユーザーを生成します。このように役割ごとに専用のメソッドを用意することで、テストコードの可読性が向上しますね。

テストでは以下のように使用します。

typescript// tests/admin.spec.ts
import { test, expect } from '@playwright/test';
import { UserFactory } from '../factories/user.factory';

test('管理者ダッシュボードへのアクセス', async ({
  page,
}) => {
  const admin = UserFactory.createAdmin();

  // API 経由で管理者ユーザーを作成
  await createUserViaAPI(admin);

  await page.goto('/login');
  await page.fill('#email', admin.email);
  await page.fill('#password', admin.password);
  await page.click('#login');

  await expect(
    page.locator('.admin-dashboard')
  ).toBeVisible();
});

このテストは Factory パターンを使って管理者ユーザーを生成し、そのユーザーでログインして管理者ダッシュボードにアクセスできることを検証します。テストデータの生成ロジックが集約されているため、メンテナンスが容易です。

固定データセットの管理

特定のシナリオで必要な固定データは、JSON ファイルで管理します。

json// data/products.json
{
  "products": [
    {
      "id": "prod-001",
      "name": "テスト商品A",
      "price": 1000,
      "stock": 10,
      "category": "electronics"
    },
    {
      "id": "prod-002",
      "name": "テスト商品B",
      "price": 2000,
      "stock": 5,
      "category": "books"
    }
  ]
}

この JSON ファイルは、商品のマスターデータとして使用します。ID や価格などの情報を一箇所で管理できるため、テストの変更に強い設計となります。

固定データを読み込むヘルパー関数を作成します。

typescript// helpers/data-loader.ts
import * as fs from 'fs';
import * as path from 'path';

export class DataLoader {
  static load<T>(filename: string): T {
    const filePath = path.join(
      __dirname,
      '../data',
      filename
    );
    const content = fs.readFileSync(filePath, 'utf-8');
    return JSON.parse(content);
  }

  static async seedDatabase(data: any[]): Promise<void> {
    // データベースへのシード処理
    for (const item of data) {
      await insertIntoDatabase(item);
    }
  }
}

DataLoader クラスは、JSON ファイルを読み込んでパースする機能と、データベースにデータを投入する機能を提供します。テストの前処理として、必要なマスターデータを簡単に準備できるでしょう。

実際の使用例は以下の通りです。

typescript// tests/product.spec.ts
import { test, expect } from '@playwright/test';
import { DataLoader } from '../helpers/data-loader';

test.beforeAll(async () => {
  const { products } = DataLoader.load('products.json');
  await DataLoader.seedDatabase(products);
});

test('商品検索機能', async ({ page }) => {
  await page.goto('/products');
  await page.fill('#search', 'テスト商品A');
  await page.click('#search-button');

  await expect(
    page.locator('.product-card').first()
  ).toContainText('テスト商品A');
});

このテストは、beforeAll フックで商品データをデータベースに投入してから、商品検索のテストを実行します。固定データを使うことで、テスト結果の予測可能性が高まりますね。

3. クリーニング戦略:テスト後のデータ削除

自動クリーンアップの実装

各テストの後に、作成したデータを自動的に削除する仕組みを構築します。

typescript// fixtures/cleanup.fixture.ts
import { test as base } from '@playwright/test';

type CleanupFixture = {
  cleanup: {
    addUser: (userId: string) => void;
    addProduct: (productId: string) => void;
  };
};

export const test = base.extend<CleanupFixture>({
  cleanup: async ({}, use) => {
    const userIds: string[] = [];
    const productIds: string[] = [];

    const cleanup = {
      addUser: (userId: string) => userIds.push(userId),
      addProduct: (productId: string) =>
        productIds.push(productId),
    };

    await use(cleanup);

    // テスト終了後にクリーンアップ実行
    await Promise.all([
      deleteUsersViaAPI(userIds),
      deleteProductsViaAPI(productIds),
    ]);
  },
});

この fixture は、テスト中に作成されたリソースの ID を記録し、テスト終了後に一括削除します。addUseraddProduct メソッドを呼び出すだけで、自動的にクリーンアップ対象として登録されるのです。

実際のテストでは以下のように使用します。

typescript// tests/order.spec.ts
import { test, expect } from '../fixtures/cleanup.fixture';
import { UserFactory } from '../factories/user.factory';

test('注文処理のテスト', async ({ page, cleanup }) => {
  const user = UserFactory.create();
  const userId = await createUserViaAPI(user);
  cleanup.addUser(userId); // クリーンアップ対象として登録

  await page.goto('/login');
  await page.fill('#email', user.email);
  await page.fill('#password', user.password);
  await page.click('#login');

  // 注文処理のテスト...
  await expect(
    page.locator('.order-success')
  ).toBeVisible();

  // テスト終了後、ユーザーは自動的に削除される
});

このテストは、ユーザーを作成した直後に cleanup.addUser() を呼び出しています。テストが成功しても失敗しても、fixture がユーザーの削除を保証するため、データの残留が防げるでしょう。

トランザクションベースのクリーンアップ

データベースを使用する場合は、トランザクションを活用してテスト後にロールバックする方法も有効です。

typescript// helpers/db-transaction.ts
import { PrismaClient } from '@prisma/client';

export class TestTransaction {
  private prisma: PrismaClient;
  private transactionId: string | null = null;

  constructor() {
    this.prisma = new PrismaClient();
  }

  async begin(): Promise<void> {
    // テスト用トランザクションを開始
    await this.prisma.$executeRaw`BEGIN`;
    this.transactionId = Date.now().toString();
  }

  async rollback(): Promise<void> {
    if (this.transactionId) {
      await this.prisma.$executeRaw`ROLLBACK`;
      this.transactionId = null;
    }
  }
}

TestTransaction クラスは、データベーストランザクションを開始し、テスト終了後にロールバックする機能を提供します。この方法を使えば、テスト中のすべてのデータ変更を一括で元に戻せますね。

トランザクションを使った fixture の実装例です。

typescript// fixtures/transaction.fixture.ts
import { test as base } from '@playwright/test';
import { TestTransaction } from '../helpers/db-transaction';

type TransactionFixture = {
  dbTransaction: TestTransaction;
};

export const test = base.extend<TransactionFixture>({
  dbTransaction: async ({}, use) => {
    const transaction = new TestTransaction();
    await transaction.begin();

    await use(transaction);

    await transaction.rollback();
  },
});

この fixture を使うと、テストが自動的にトランザクション内で実行され、終了時にロールバックされます。

typescript// tests/database.spec.ts
import {
  test,
  expect,
} from '../fixtures/transaction.fixture';

test('データベース操作のテスト', async ({
  page,
  dbTransaction,
}) => {
  // このテスト内のすべてのDB操作はトランザクション内で実行される
  await page.goto('/admin/users');
  await page.click('#create-user');
  await page.fill('#username', 'testuser');
  await page.click('#save');

  await expect(page.locator('.success')).toBeVisible();

  // テスト終了後、すべての変更が自動的にロールバックされる
});

このテストでは、ユーザーの作成操作がトランザクション内で実行されます。テスト終了後、データベースは元の状態に戻るため、次のテストに影響を与えません。

テストデータ戦略の全体フロー

これまでに紹介した 3 つの戦略(分離・再現・クリーニング)を組み合わせた全体のフローを図示します。

mermaidsequenceDiagram
  participant Test as テストコード
  participant Fixture as Fixture/Factory
  participant DB as データベース
  participant App as テスト対象アプリ
  participant Cleanup as クリーンアップ

  Test->>Fixture: テストデータ要求
  Fixture->>Fixture: 一意なデータ生成<br/>(分離戦略)
  Fixture->>DB: データ作成
  DB-->>Fixture: データID返却
  Fixture->>Cleanup: クリーンアップ対象登録
  Fixture-->>Test: テストデータ返却

  Test->>App: UI操作実行
  App->>DB: データ参照/更新
  DB-->>App: 結果返却
  App-->>Test: UI応答

  Test->>Test: アサーション検証

  Test->>Cleanup: テスト終了
  Cleanup->>DB: データ削除
  DB-->>Cleanup: 削除完了
  Cleanup-->>Test: クリーンアップ完了

この図は、テストデータのライフサイクル全体を示しています。Fixture がデータを生成し、クリーンアップ対象として登録し、テスト終了後に削除するという一連の流れが確認できるでしょう。

具体例

実践例:EC サイトの注文フローテスト

実際の EC サイトの注文フローを例に、これまでの戦略を統合したテストを実装します。

テストシナリオの設定

以下のような注文フローをテストします。

  1. ユーザーがログインする
  2. 商品を検索してカートに追加する
  3. 注文を確定する
  4. 注文完了メッセージを確認する

データファクトリーの準備

まず、必要なデータファクトリーを作成します。

typescript// factories/order.factory.ts
import { faker } from '@faker-js/faker';
import { UserFactory } from './user.factory';

export class OrderFactory {
  static create(overrides?: Partial<Order>): Order {
    return {
      id: faker.string.uuid(),
      userId: faker.string.uuid(),
      items: [
        {
          productId: 'prod-001',
          quantity: 1,
          price: 1000,
        },
      ],
      totalAmount: 1000,
      status: 'pending',
      createdAt: new Date(),
      ...overrides,
    };
  }

  static createWithUser(): { user: User; order: Order } {
    const user = UserFactory.create();
    const order = this.create({ userId: user.id });
    return { user, order };
  }
}

OrderFactory は注文データを生成し、createWithUser メソッドでユーザーと注文をセットで作成できます。関連するデータを一括で生成することで、テストコードがシンプルになりますね。

統合テストの実装

分離・再現・クリーニングのすべての戦略を適用した統合テストです。

typescript// tests/order-flow.spec.ts
import { test, expect } from '@playwright/test';
import { UserFactory } from '../factories/user.factory';
import { DataLoader } from '../helpers/data-loader';
import { v4 as uuidv4 } from 'uuid';

// 環境分離:テスト環境の確認
test.beforeAll(async () => {
  expect(process.env.TEST_BASE_URL).toBeDefined();
  expect(process.env.TEST_BASE_URL).not.toContain(
    'production'
  );
});

このコードは、テスト実行前に環境設定を確認します。本番環境でテストが実行されないよう、URL に "production" が含まれていないことをチェックしていますね。

次に、テストデータの準備とクリーンアップの設定を行います。

typescripttest("注文フロー完全テスト", async ({ page }) => {
  // 1. データ再現:一意なユーザーを作成
  const uniqueId = uuidv4();
  const user = UserFactory.create({
    email: `test-${uniqueId}@example.com`,
    username: `testuser-${uniqueId}`,
  });

  // API経由でユーザーを作成
  const createdUser = await fetch(
    `${process.env.API_BASE_URL}/users`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(user),
    }
  ).then((res) => res.json());

  const userId = createdUser.id;

この部分では、UUID を使って一意なユーザーを生成し、API 経由でデータベースに作成しています。Factory パターンにより、必要な項目だけをカスタマイズできる柔軟性が保たれています。

ログインから商品検索までの操作を実装します。

typescript  try {
    // 2. ログイン操作
    await page.goto("/login");
    await page.fill("#email", user.email);
    await page.fill("#password", user.password);
    await page.click("#login-button");

    await expect(page.locator(".user-menu")).toContainText(user.username);

    // 3. 商品検索とカート追加
    await page.goto("/products");
    await page.fill("#search", "テスト商品A");
    await page.click("#search-button");

    const firstProduct = page.locator(".product-card").first();
    await expect(firstProduct).toContainText("テスト商品A");

    await firstProduct.locator(".add-to-cart").click();
    await expect(page.locator(".cart-count")).toContainText("1");

この部分では、ユーザーがログインし、商品を検索してカートに追加する一連の操作を実行しています。各ステップで適切なアサーションを行うことで、操作が正しく完了したことを確認できますね。

注文確定とクリーンアップの処理を実装します。

typescript    // 4. 注文確定
    await page.click(".cart-icon");
    await page.click("#checkout-button");

    await page.fill("#shipping-address", "東京都渋谷区テスト1-2-3");
    await page.fill("#phone", "03-1234-5678");
    await page.click("#place-order");

    // 5. 注文完了確認
    await expect(page.locator(".order-success")).toBeVisible();
    await expect(page.locator(".order-success")).toContainText("注文が完了しました");

    const orderNumber = await page.locator(".order-number").textContent();
    expect(orderNumber).toMatch(/^ORD-\d+$/);

  } finally {
    // 6. クリーンアップ:作成したデータを削除
    await fetch(`${process.env.API_BASE_URL}/users/${userId}`, {
      method: "DELETE",
    });
  }
});

このコードは、注文を確定して完了メッセージを確認した後、finally ブロックで必ずユーザーを削除します。テストが成功しても失敗しても、データが残らないことが保証されるでしょう。

パフォーマンステストでのデータ管理

大量のテストデータを扱う場合の戦略も見ていきます。

typescript// tests/performance.spec.ts
import { test, expect } from "@playwright/test";
import { UserFactory } from "../factories/user.factory";

test.describe("パフォーマンステスト", () => {
  const USERS_COUNT = 100;
  const createdUserIds: string[] = [];

  test.beforeAll(async () => {
    // 大量のテストユーザーを一括作成
    const users = UserFactory.createMany(USERS_COUNT);

    const promises = users.map((user) =>
      fetch(`${process.env.API_BASE_URL}/users`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(user),
      })
        .then((res) => res.json())
        .then((created) => createdUserIds.push(created.id))
    );

    await Promise.all(promises);
  });

この前処理では、100 人のユーザーを並行して作成しています。Promise.all を使うことで、効率的にデータをセットアップできますね。

パフォーマンステストの本体とクリーンアップを実装します。

typescript  test("ユーザー一覧の表示速度", async ({ page }) => {
    const startTime = Date.now();

    await page.goto("/admin/users");
    await page.waitForSelector(".user-list-item");

    const loadTime = Date.now() - startTime;

    // 100人のユーザーが2秒以内に表示されることを確認
    expect(loadTime).toBeLessThan(2000);

    const userCount = await page.locator(".user-list-item").count();
    expect(userCount).toBeGreaterThanOrEqual(USERS_COUNT);
  });

  test.afterAll(async () => {
    // すべてのテストユーザーを削除
    const promises = createdUserIds.map((id) =>
      fetch(`${process.env.API_BASE_URL}/users/${id}`, {
        method: "DELETE",
      })
    );

    await Promise.all(promises);
  });
});

このテストは、100 人のユーザーが 2 秒以内に表示されることを検証します。テスト終了後は、作成したすべてのユーザーを並行削除することで、効率的にクリーンアップを実行できるでしょう。

データ分離の比較表

これまでに紹介した分離戦略を、わかりやすく比較してみましょう。

#戦略メリットデメリット適用場面
1UUID による一意性確保データ競合が完全に防げる可読性がやや低下する並行実行が必要な場合
2環境変数による分離本番環境を保護できる環境ごとの設定が必要すべてのテスト
3トランザクション分離高速で完全なクリーンアップDB トランザクションが必須DB を直接操作する場合
4テストデータ接頭辞データの識別が容易アプリ側の対応が必要共有環境でのテスト
5専用テストデータベース完全な独立性インフラ管理の負担増加大規模プロジェクト

この表から、それぞれの戦略が異なる場面で有効であることがわかります。プロジェクトの規模や要件に応じて、適切な組み合わせを選択することが重要です。

エラーハンドリングとデータ整合性

テストデータの管理において、エラーが発生した場合の対応も重要です。

typescript// helpers/safe-cleanup.ts
export class SafeCleanup {
  private resources: Map<string, () => Promise<void>> =
    new Map();

  register(id: string, cleanup: () => Promise<void>): void {
    this.resources.set(id, cleanup);
  }

  async execute(): Promise<void> {
    const errors: Error[] = [];

    for (const [id, cleanup] of this.resources) {
      try {
        await cleanup();
      } catch (error) {
        console.error(`クリーンアップ失敗: ${id}`, error);
        errors.push(error as Error);
      }
    }

    if (errors.length > 0) {
      console.warn(
        `${errors.length}件のクリーンアップが失敗しました`
      );
    }
  }
}

SafeCleanup クラスは、複数のリソースのクリーンアップを安全に実行します。一部のクリーンアップが失敗しても、他のリソースの削除を継続するため、データの残留を最小限に抑えられるでしょう。

実際の使用例です。

typescript// tests/with-safe-cleanup.spec.ts
import { test, expect } from '@playwright/test';
import { SafeCleanup } from '../helpers/safe-cleanup';
import { UserFactory } from '../factories/user.factory';

test('安全なクリーンアップ付きテスト', async ({ page }) => {
  const cleanup = new SafeCleanup();

  try {
    const user = UserFactory.create();
    const userId = await createUserViaAPI(user);

    // クリーンアップ処理を登録
    cleanup.register(`user-${userId}`, async () => {
      await deleteUserViaAPI(userId);
    });

    await page.goto('/login');
    await page.fill('#email', user.email);
    await page.fill('#password', user.password);
    await page.click('#login');

    await expect(page.locator('.dashboard')).toBeVisible();
  } finally {
    // すべてのクリーンアップを安全に実行
    await cleanup.execute();
  }
});

このテストは、SafeCleanup を使ってリソースのクリーンアップを確実に実行します。エラーが発生してもログに記録されるため、問題の特定が容易になりますね。

まとめ

Playwright を使ったテストデータ設計において、分離・再現・クリーニングという 3 つの戦略を適切に組み合わせることで、安定した高品質なテスト環境を構築できます。

本記事で紹介した主要なポイントを振り返りましょう。

分離戦略のポイント

  • 環境変数を使って本番環境とテスト環境を完全に分離する
  • UUID やタイムスタンプを使って各テストで一意なデータを生成する
  • Fixture を活用してテスト間のデータ競合を防ぐ

再現戦略のポイント

  • Factory パターンでテストデータの生成ロジックを集約する
  • Faker などのライブラリを活用してリアルなテストデータを生成する
  • 固定データが必要な場合は JSON ファイルで管理し、一元化する

クリーンアップ戦略のポイント

  • Fixture の finally ブロックで確実にリソースを削除する
  • データベースのトランザクションを活用して高速なロールバックを実現する
  • 複数のリソースがある場合は SafeCleanup のような仕組みで安全に削除する

これらの戦略を組み合わせることで、以下のような効果が期待できます。

  1. テストの信頼性向上:データ競合や環境依存の問題が解消され、安定したテスト結果が得られます
  2. 開発効率の向上:テストの失敗原因が明確になり、デバッグ時間が短縮されます
  3. メンテナンス性の向上:テストデータの管理が集約され、変更に強い設計になります
  4. チーム全体の生産性向上:誰でも同じ環境でテストを実行でき、並行開発がスムーズになります

テストデータの適切な管理は、単なるテストの問題ではなく、プロジェクト全体の品質とスピードに直結する重要な要素です。本記事で紹介した戦略を、ぜひ皆さんのプロジェクトに取り入れてみてください。最初は小さな範囲から始めて、徐々に適用範囲を広げていくことをお勧めします。

安定したテスト環境があれば、自信を持ってコードをリファクタリングでき、新機能の追加もスムーズに進められるでしょう。テストデータ設計への投資は、必ず開発体験の向上という形で返ってくるはずです。

関連リンク