T-CREATOR

Playwright と Jest の連携ベストプラクティス

Playwright と Jest の連携ベストプラクティス

現代の Web アプリケーション開発において、テストの重要性は年々高まっています。特に、フロントエンドとバックエンドの両方をカバーする包括的なテスト戦略が求められる中で、Playwright と Jest の連携は非常に強力なソリューションとなります。

この記事では、Playwright と Jest を効果的に組み合わせることで、開発効率を大幅に向上させ、品質の高いアプリケーションを構築する方法について詳しく解説します。初心者の方でも理解しやすいよう、段階的に進めていきましょう。

Playwright と Jest の基本概念

Playwright とは

Playwright は、Microsoft が開発した現代的な Web アプリケーション用の自動化ライブラリです。Chrome、Firefox、Safari の 3 つのブラウザエンジンをサポートし、高速で信頼性の高い E2E テストを実行できます。

typescript// Playwright の基本的なテスト例
import { test, expect } from '@playwright/test';

test('ログインテスト', async ({ page }) => {
  await page.goto('https://example.com/login');
  await page.fill(
    '[data-testid="email"]',
    'user@example.com'
  );
  await page.fill(
    '[data-testid="password"]',
    'password123'
  );
  await page.click('[data-testid="login-button"]');

  await expect(
    page.locator('[data-testid="dashboard"]')
  ).toBeVisible();
});

Playwright の最大の特徴は、自動待機機能と強力なセレクター機能です。これにより、フレークテスト(時々失敗するテスト)を大幅に削減できます。

Jest とは

Jest は、Facebook が開発した JavaScript 用のテストフレームワークです。設定が簡単で、豊富な機能を備えており、多くの開発者に愛用されています。

javascript// Jest の基本的なテスト例
describe('ユーザー認証', () => {
  test('有効なメールアドレスを検証する', () => {
    const isValidEmail = (email) => {
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      return emailRegex.test(email);
    };

    expect(isValidEmail('user@example.com')).toBe(true);
    expect(isValidEmail('invalid-email')).toBe(false);
  });
});

Jest は、モック機能、スナップショットテスト、カバレッジレポートなど、現代的なテストに必要な機能をすべて提供します。

なぜ連携が必要なのか

Playwright と Jest を単独で使用する場合、それぞれに限界があります。Playwright は E2E テストに特化していますが、ユニットテストやコンポーネントテストには適していません。一方、Jest はユニットテストに優れていますが、ブラウザ環境でのテストには制限があります。

両者を連携させることで、以下のメリットが得られます:

  • 統合されたテスト環境: 一つのプロジェクトで全てのテストを管理
  • 効率的なテスト実行: 並列実行による時間短縮
  • 豊富なアサーション機能: Jest の強力なマッチャーを活用
  • 一貫したレポート: 統一されたテスト結果の表示

環境構築とセットアップ

プロジェクトの初期化

まず、新しいプロジェクトを作成し、必要な依存関係をインストールしましょう。

bash# プロジェクトディレクトリの作成
mkdir playwright-jest-integration
cd playwright-jest-integration

# package.json の初期化
yarn init -y

必要なパッケージのインストール

Playwright と Jest の連携に必要なパッケージをインストールします。

bash# Playwright のインストール
yarn add -D @playwright/test

# Jest と関連パッケージのインストール
yarn add -D jest @types/jest ts-jest

# TypeScript の設定
yarn add -D typescript @types/node

# ブラウザのインストール
npx playwright install

設定ファイルの作成

プロジェクトのルートに設定ファイルを作成します。

javascript// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src', '<rootDir>/tests'],
  testMatch: [
    '**/__tests__/**/*.ts',
    '**/?(*.)+(spec|test).ts',
  ],
  transform: {
    '^.+\\.ts$': 'ts-jest',
  },
  collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'],
};
typescript// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
  webServer: {
    command: 'yarn dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});
json// package.json の scripts セクション
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:all": "yarn test && yarn test:e2e"
  }
}

基本的なテストの書き方

Playwright テストの構造

Playwright テストは、test 関数と expect 関数を使用して記述します。

typescript// tests/e2e/login.spec.ts
import { test, expect } from '@playwright/test';

test.describe('ログイン機能', () => {
  test.beforeEach(async ({ page }) => {
    // 各テストの前に実行される処理
    await page.goto('/login');
  });

  test('正常なログイン', async ({ page }) => {
    // テストデータの準備
    const testUser = {
      email: 'test@example.com',
      password: 'password123',
    };

    // フォームの入力
    await page.fill(
      '[data-testid="email"]',
      testUser.email
    );
    await page.fill(
      '[data-testid="password"]',
      testUser.password
    );

    // ログインボタンのクリック
    await page.click('[data-testid="login-button"]');

    // 結果の検証
    await expect(page).toHaveURL('/dashboard');
    await expect(
      page.locator('[data-testid="user-name"]')
    ).toContainText('Test User');
  });

  test('無効な認証情報でのログイン', async ({ page }) => {
    await page.fill(
      '[data-testid="email"]',
      'invalid@example.com'
    );
    await page.fill(
      '[data-testid="password"]',
      'wrongpassword'
    );
    await page.click('[data-testid="login-button"]');

    // エラーメッセージの確認
    await expect(
      page.locator('[data-testid="error-message"]')
    ).toBeVisible();
    await expect(
      page.locator('[data-testid="error-message"]')
    ).toContainText('認証に失敗しました');
  });
});

Jest との統合方法

Jest のテストと Playwright のテストを統合するために、共通のユーティリティ関数を作成します。

typescript// tests/utils/test-helpers.ts
import { Page } from '@playwright/test';

export class TestHelpers {
  static async login(
    page: Page,
    email: string,
    password: string
  ) {
    await page.goto('/login');
    await page.fill('[data-testid="email"]', email);
    await page.fill('[data-testid="password"]', password);
    await page.click('[data-testid="login-button"]');
  }

  static async waitForNavigation(page: Page) {
    await page.waitForLoadState('networkidle');
  }

  static async takeScreenshot(page: Page, name: string) {
    await page.screenshot({
      path: `screenshots/${name}.png`,
    });
  }
}

テストケースの作成例

実際のアプリケーションでよく使用されるテストケースの例を示します。

typescript// tests/e2e/user-management.spec.ts
import { test, expect } from '@playwright/test';
import { TestHelpers } from '../utils/test-helpers';

test.describe('ユーザー管理機能', () => {
  test('ユーザー一覧の表示', async ({ page }) => {
    // 管理者としてログイン
    await TestHelpers.login(
      page,
      'admin@example.com',
      'admin123'
    );

    // ユーザー管理ページに移動
    await page.click(
      '[data-testid="user-management-link"]'
    );

    // ユーザー一覧の確認
    const userRows = page.locator(
      '[data-testid="user-row"]'
    );
    await expect(userRows).toHaveCount(5);

    // 検索機能のテスト
    await page.fill('[data-testid="search-input"]', 'john');
    await page.keyboard.press('Enter');

    const filteredRows = page.locator(
      '[data-testid="user-row"]'
    );
    await expect(filteredRows).toHaveCount(1);
  });

  test('新規ユーザーの作成', async ({ page }) => {
    await TestHelpers.login(
      page,
      'admin@example.com',
      'admin123'
    );
    await page.click(
      '[data-testid="user-management-link"]'
    );
    await page.click('[data-testid="add-user-button"]');

    // ユーザー情報の入力
    await page.fill(
      '[data-testid="user-name"]',
      'New User'
    );
    await page.fill(
      '[data-testid="user-email"]',
      'newuser@example.com'
    );
    await page.selectOption(
      '[data-testid="user-role"]',
      'user'
    );

    await page.click('[data-testid="save-button"]');

    // 成功メッセージの確認
    await expect(
      page.locator('[data-testid="success-message"]')
    ).toBeVisible();
  });
});

高度なテストパターン

ページオブジェクトモデルの活用

ページオブジェクトモデル(POM)を使用することで、テストコードの保守性と可読性を大幅に向上させられます。

typescript// tests/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.locator('[data-testid="email"]');
    this.passwordInput = page.locator(
      '[data-testid="password"]'
    );
    this.loginButton = page.locator(
      '[data-testid="login-button"]'
    );
    this.errorMessage = page.locator(
      '[data-testid="error-message"]'
    );
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }

  async getErrorMessage() {
    return await this.errorMessage.textContent();
  }
}
typescript// tests/pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';

export class DashboardPage {
  readonly page: Page;
  readonly welcomeMessage: Locator;
  readonly userMenu: Locator;
  readonly logoutButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.welcomeMessage = page.locator(
      '[data-testid="welcome-message"]'
    );
    this.userMenu = page.locator(
      '[data-testid="user-menu"]'
    );
    this.logoutButton = page.locator(
      '[data-testid="logout-button"]'
    );
  }

  async isVisible() {
    return await this.welcomeMessage.isVisible();
  }

  async logout() {
    await this.userMenu.click();
    await this.logoutButton.click();
  }
}

カスタムマッチャーの作成

Jest のカスタムマッチャーを作成して、より読みやすいテストを書けます。

typescript// tests/matchers/custom-matchers.ts
import { expect } from '@jest/globals';

declare global {
  namespace jest {
    interface Matchers<R> {
      toBeValidEmail(): R;
      toHaveValidPassword(): R;
    }
  }
}

expect.extend({
  toBeValidEmail(received: string) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    const pass = emailRegex.test(received);

    if (pass) {
      return {
        message: () =>
          `expected ${received} not to be a valid email`,
        pass: true,
      };
    } else {
      return {
        message: () =>
          `expected ${received} to be a valid email`,
        pass: false,
      };
    }
  },

  toHaveValidPassword(received: string) {
    const hasMinLength = received.length >= 8;
    const hasUpperCase = /[A-Z]/.test(received);
    const hasLowerCase = /[a-z]/.test(received);
    const hasNumber = /\d/.test(received);

    const pass =
      hasMinLength &&
      hasUpperCase &&
      hasLowerCase &&
      hasNumber;

    if (pass) {
      return {
        message: () =>
          `expected ${received} not to be a valid password`,
        pass: true,
      };
    } else {
      return {
        message: () =>
          `expected ${received} to be a valid password`,
        pass: false,
      };
    }
  },
});

モックとスタブの活用

Jest のモック機能を活用して、外部依存関係を制御します。

typescript// tests/unit/auth-service.test.ts
import { AuthService } from '../../src/services/auth-service';

// モックの設定
jest.mock('../../src/services/api-client');

describe('AuthService', () => {
  let authService: AuthService;

  beforeEach(() => {
    authService = new AuthService();
  });

  test('正常なログイン処理', async () => {
    // API クライアントのモック
    const mockApiClient = require('../../src/services/api-client');
    mockApiClient.post.mockResolvedValue({
      data: {
        token: 'mock-token',
        user: { id: 1, name: 'Test User' },
      },
    });

    const result = await authService.login(
      'test@example.com',
      'password123'
    );

    expect(result.success).toBe(true);
    expect(result.token).toBe('mock-token');
    expect(mockApiClient.post).toHaveBeenCalledWith(
      '/auth/login',
      {
        email: 'test@example.com',
        password: 'password123',
      }
    );
  });

  test('認証失敗時の処理', async () => {
    const mockApiClient = require('../../src/services/api-client');
    mockApiClient.post.mockRejectedValue(
      new Error('認証に失敗しました')
    );

    const result = await authService.login(
      'invalid@example.com',
      'wrongpassword'
    );

    expect(result.success).toBe(false);
    expect(result.error).toBe('認証に失敗しました');
  });
});

CI/CD パイプラインでの活用

GitHub Actions での設定

GitHub Actions を使用して、自動化されたテストパイプラインを構築します。

yaml# .github/workflows/test.yml
name: Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  unit-tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'yarn'

      - name: Install dependencies
        run: yarn install --frozen-lockfile

      - name: Run unit tests
        run: yarn test --coverage

      - name: Upload coverage reports
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage/lcov.info

  e2e-tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'yarn'

      - name: Install dependencies
        run: yarn install --frozen-lockfile

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Build application
        run: yarn build

      - name: Start application
        run: yarn start &

      - name: Wait for application
        run: npx wait-on http://localhost:3000

      - name: Run E2E tests
        run: npx playwright test

      - name: Upload test results
        uses: actions/upload-artifact@v3
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

並列実行の最適化

テストの実行時間を短縮するために、並列実行を設定します。

typescript// playwright.config.ts の並列実行設定
export default defineConfig({
  workers: process.env.CI ? 4 : undefined, // CI環境では4つのワーカーを使用
  fullyParallel: true, // 完全並列実行を有効化

  projects: [
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
      dependencies: ['setup'],
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
      dependencies: ['setup'],
    },
  ],
});

レポート生成と通知

テスト結果を視覚的に分かりやすく表示し、チームに通知します。

typescript// playwright.config.ts のレポート設定
export default defineConfig({
  reporter: [
    ['html'], // HTML レポート
    ['json', { outputFile: 'test-results.json' }], // JSON レポート
    ['junit', { outputFile: 'test-results.xml' }], // JUnit レポート
  ],

  // テスト失敗時のスクリーンショット
  use: {
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    trace: 'retain-on-failure',
  },
});
yaml# .github/workflows/test.yml の通知設定
- name: Notify test results
  if: always()
  uses: 8398a7/action-slack@v3
  with:
    status: ${{ job.status }}
    channel: '#test-results'
    webhook_url: ${{ secrets.SLACK_WEBHOOK }}

パフォーマンスとメンテナンス

テスト実行時間の最適化

テストの実行時間を短縮するためのベストプラクティスを実装します。

typescript// tests/e2e/optimized-test.spec.ts
import { test, expect } from '@playwright/test';

// テストデータの共有
const testUsers = [
  { email: 'user1@example.com', password: 'password123' },
  { email: 'user2@example.com', password: 'password456' },
];

test.describe('最適化されたテスト', () => {
  // 共有のセットアップ
  test.beforeAll(async ({ browser }) => {
    // ブラウザコンテキストの共有
    const context = await browser.newContext();
    // 認証状態の保存
    await context.addCookies([
      {
        name: 'auth-token',
        value: 'mock-token',
        domain: 'localhost',
      },
    ]);
  });

  // 並列実行可能なテスト
  test('ユーザー一覧の表示', async ({ page }) => {
    await page.goto('/users');

    // 効率的なセレクターの使用
    const userList = page.locator(
      '[data-testid="user-list"]'
    );
    await expect(userList).toBeVisible();

    // 一括での要素確認
    const userItems = page.locator(
      '[data-testid="user-item"]'
    );
    await expect(userItems).toHaveCount(10);
  });

  // パラメータ化テスト
  for (const user of testUsers) {
    test(`ログインテスト: ${user.email}`, async ({
      page,
    }) => {
      await page.goto('/login');
      await page.fill('[data-testid="email"]', user.email);
      await page.fill(
        '[data-testid="password"]',
        user.password
      );
      await page.click('[data-testid="login-button"]');

      await expect(page).toHaveURL('/dashboard');
    });
  }
});

フレークテストの対策

フレークテスト(時々失敗するテスト)を防ぐための対策を実装します。

typescript// tests/utils/stable-test-helpers.ts
import { Page } from '@playwright/test';

export class StableTestHelpers {
  // 安定した待機処理
  static async waitForElement(
    page: Page,
    selector: string,
    timeout = 10000
  ) {
    await page.waitForSelector(selector, {
      state: 'visible',
      timeout,
    });
  }

  // ネットワークアイドル状態の待機
  static async waitForNetworkIdle(page: Page) {
    await page.waitForLoadState('networkidle');
  }

  // 安定したクリック処理
  static async stableClick(page: Page, selector: string) {
    await page.waitForSelector(selector, {
      state: 'visible',
    });
    await page.click(selector);
  }

  // 安定した入力処理
  static async stableFill(
    page: Page,
    selector: string,
    value: string
  ) {
    await page.waitForSelector(selector, {
      state: 'visible',
    });
    await page.fill(selector, value);
  }

  // リトライ機能付きアサーション
  static async retryAssertion(
    assertion: () => Promise<void>,
    maxRetries = 3,
    delay = 1000
  ) {
    for (let i = 0; i < maxRetries; i++) {
      try {
        await assertion();
        return;
      } catch (error) {
        if (i === maxRetries - 1) throw error;
        await new Promise((resolve) =>
          setTimeout(resolve, delay)
        );
      }
    }
  }
}

テストコードの保守性向上

テストコードの保守性を向上させるためのベストプラクティスを実装します。

typescript// tests/constants/test-data.ts
export const TEST_DATA = {
  users: {
    admin: {
      email: 'admin@example.com',
      password: 'admin123',
      name: 'Admin User',
    },
    regular: {
      email: 'user@example.com',
      password: 'user123',
      name: 'Regular User',
    },
  },
  urls: {
    login: '/login',
    dashboard: '/dashboard',
    users: '/users',
  },
  selectors: {
    emailInput: '[data-testid="email"]',
    passwordInput: '[data-testid="password"]',
    loginButton: '[data-testid="login-button"]',
    errorMessage: '[data-testid="error-message"]',
  },
} as const;
typescript// tests/fixtures/test-fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

// カスタムフィクスチャの定義
type TestFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  authenticatedPage: any;
};

export const test = base.extend<TestFixtures>({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await use(loginPage);
  },

  dashboardPage: async ({ page }, use) => {
    const dashboardPage = new DashboardPage(page);
    await use(dashboardPage);
  },

  authenticatedPage: async ({ page }, use) => {
    // 認証済みのページコンテキストを作成
    await page.goto('/login');
    await page.fill(
      '[data-testid="email"]',
      'test@example.com'
    );
    await page.fill(
      '[data-testid="password"]',
      'password123'
    );
    await page.click('[data-testid="login-button"]');
    await page.waitForURL('/dashboard');

    await use(page);
  },
});

export { expect } from '@playwright/test';

まとめ

Playwright と Jest の連携により、現代的な Web アプリケーション開発に必要な包括的なテスト環境を構築できます。この記事で紹介したベストプラクティスを実践することで、以下のような効果が期待できます:

開発効率の向上

  • 統合されたテスト環境による開発フローの簡素化
  • 並列実行によるテスト時間の短縮
  • 自動化された CI/CD パイプライン

品質の向上

  • 包括的なテストカバレッジ
  • フレークテストの削減
  • 早期のバグ発見

保守性の向上

  • 構造化されたテストコード
  • 再利用可能なコンポーネント
  • 明確なドキュメント化

実際のプロジェクトでこれらの手法を適用する際は、段階的に導入することをお勧めします。まずは基本的なセットアップから始め、徐々に高度な機能を追加していくことで、チーム全体が無理なく新しいテスト環境に慣れることができます。

テストは開発の品質を保証する重要な要素です。Playwright と Jest の連携を活用して、自信を持ってリリースできる高品質なアプリケーションを構築しましょう。

関連リンク