mcpField
P
Playwright API Testing

Playwright API Testing

github.com/pramodduttaテスト・品質24461 回インストール1 週間前に更新
リポジトリ

Playwright API Testing について

Playwright API Testingは、PlaywrightでAPIテストを作成、レビュー、デバッグするスキルです。QA自動化エンジニア向けに、型付きペイロード、テストデータの分離、包括的なレスポンス検証、再利用可能なAPIクライアントを扱います。

主な機能

  1. 01Playwright APIRequestContextの利用
  2. 02リクエストとレスポンスの型定義
  3. 03テストデータの独立したライフサイクル管理
  4. 04ステータス、本文、ヘッダー、時間の検証
  5. 05サービス領域ごとのAPIクライアント再利用
  6. 06認証、ページネーション、エラーの網羅

活用シーン

  1. 01CRUDエンドポイントのテスト作成と検証
  2. 02認証、認可、エラー処理の確認
  3. 03ページネーション、フィルタリング、レスポンスヘッダーのテスト
  4. 04API性能とファイルアップロードの検証

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/pramoddutta/qaskills --skill playwright-api-testing

インストール後、Claude Code で/playwright-api-testingを入力して読み込まれたか確認します。

詳細

Playwright API テストスキル

Playwright の組み込み APIRequestContext を使用した API テストを専門とする、経験豊富な QA 自動化エンジニアとして対応してください。ユーザーから Playwright による API テストの作成、レビュー、デバッグを依頼された場合は、以下の詳細な指示に従ってください。

基本原則

  1. Playwright ネイティブの API テスト -- 外部の HTTP ライブラリではなく、APIRequestContext を使用します。
  2. 型安全性 -- すべてのリクエスト/レスポンスのペイロードにインターフェースを定義します。
  3. 分離 -- 各テストでデータのライフサイクル(作成、検証、クリーンアップ)を管理します。
  4. 包括的な検証 -- ステータスコード、ヘッダー、レスポンスボディの構造、所要時間を確認します。
  5. 再利用可能な抽象化 -- サービスの各ドメインに API クライアントクラスを作成します。

プロジェクト構成

tests/
  api/
    auth/
      auth-api.spec.ts
    users/
      users-api.spec.ts
      users-crud.spec.ts
    products/
      products-api.spec.ts
  fixtures/
    api.fixture.ts
    auth-api.fixture.ts
  models/
    user.model.ts
    product.model.ts
    api-response.model.ts
  clients/
    base-api-client.ts
    users-api-client.ts
    products-api-client.ts
  utils/
    api-helpers.ts
    schema-validator.ts
playwright.config.ts

設定

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests/api',
  fullyParallel: true,
  retries: process.env.CI ? 1 : 0,
  reporter: [
    ['html'],
    ['json', { outputFile: 'test-results/api-results.json' }],
  ],
  use: {
    baseURL: process.env.API_BASE_URL || 'http://localhost:3000/api',
    extraHTTPHeaders: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
    },
  },
});

レスポンスモデル

すべての API ペイロードに TypeScript インターフェースを定義します。

// models/user.model.ts
export interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'user' | 'viewer';
  createdAt: string;
  updatedAt: string;
}

export interface CreateUserRequest {
  email: string;
  name: string;
  password: string;
  role?: 'admin' | 'user' | 'viewer';
}

export interface UpdateUserRequest {
  name?: string;
  role?: 'admin' | 'user' | 'viewer';
}

export interface UserListResponse {
  data: User[];
  total: number;
  page: number;
  pageSize: number;
}

export interface ApiError {
  statusCode: number;
  message: string;
  error: string;
  details?: Record<string, string[]>;
}

基本 API クライアント

// clients/base-api-client.ts
import { APIRequestContext, APIResponse } from '@playwright/test';

export class BaseApiClient {
  protected readonly request: APIRequestContext;
  protected readonly basePath: string;

  constructor(request: APIRequestContext, basePath: string) {
    this.request = request;
    this.basePath = basePath;
  }

  protected async get(path: string, params?: Record<string, string>): Promise<APIResponse> {
    const url = params
      ? `${this.basePath}${path}?${new URLSearchParams(params)}`
      : `${this.basePath}${path}`;
    return this.request.get(url);
  }

  protected async post(path: string, data: unknown): Promise<APIResponse> {
    return this.request.post(`${this.basePath}${path}`, { data });
  }

  protected async put(path: string, data: unknown): Promise<APIResponse> {
    return this.request.put(`${this.basePath}${path}`, { data });
  }

  protected async patch(path: string, data: unknown): Promise<APIResponse> {
    return this.request.patch(`${this.basePath}${path}`, { data });
  }

  protected async delete(path: string): Promise<APIResponse> {
    return this.request.delete(`${this.basePath}${path}`);
  }
}
ドメイン固有の API クライアント
// clients/users-api-client.ts
import { APIRequestContext, APIResponse } from '@playwright/test';
import { BaseApiClient } from './base-api-client';
import { CreateUserRequest, UpdateUserRequest } from '../models/user.model';

export class UsersApiClient extends BaseApiClient {
  constructor(request: APIRequestContext) {
    super(request, '/users');
  }

  async list(page = 1, pageSize = 10): Promise<APIResponse> {
    return this.get('', { page: String(page), pageSize: String(pageSize) });
  }

  async getById(id: string): Promise<APIResponse> {
    return this.get(`/${id}`);
  }

  async create(user: CreateUserRequest): Promise<APIResponse> {
    return this.post('', user);
  }

  async update(id: string, data: UpdateUserRequest): Promise<APIResponse> {
    return this.patch(`/${id}`, data);
  }

  async remove(id: string): Promise<APIResponse> {
    return this.delete(`/${id}`);
  }

  async search(query: string): Promise<APIResponse> {
    return this.get('/search', { q: query });
  }
}

カスタムフィクスチャ

// fixtures/api.fixture.ts
import { test as base } from '@playwright/test';
import { UsersApiClient } from '../clients/users-api-client';
import { ProductsApiClient } from '../clients/products-api-client';

type ApiFixtures = {
  usersApi: UsersApiClient;
  productsApi: ProductsApiClient;
  authToken: string;
};

export const test = base.extend<ApiFixtures>({
  usersApi: async ({ request }, use) => {
    await use(new UsersApiClient(request));
  },

  productsApi: async ({ request }, use) => {
    await use(new ProductsApiClient(request));
  },

  authToken: async ({ request }, use) => {
    const response = await request.post('/auth/login', {
      data: {
        email: '[email protected]',
        password: 'AdminPass123!',
      },
    });
    const body = await response.json();
    await use(body.token);
  },
});

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

API テストの作成

CRUD 操作
import { test, expect } from '../fixtures/api.fixture';
import { CreateUserRequest, User } from '../models/user.model';

test.describe('Users API - CRUD', () => {
  let createdUserId: string;

  const newUser: CreateUserRequest = {
    email: `test-${Date.now()}@example.com`,
    name: 'Test User',
    password: 'SecurePass123!',
    role: 'user',
  };

  test('POST /users - should create a new user', async ({ usersApi }) => {
    const response = await usersApi.create(newUser);

    expect(response.status()).toBe(201);

    const body: User = await response.json();
    expect(body.id).toBeTruthy();
    expect(body.email).toBe(newUser.email);
    expect(body.name).toBe(newUser.name);
    expect(body.role).toBe('user');
    expect(body.createdAt).toBeTruthy();

    createdUserId = body.id;
  });

  test('GET /users/:id - should retrieve the user', async ({ usersApi }) => {
    // First create a user
    const createResponse = await usersApi.create({
      ...newUser,
      email: `get-test-${Date.now()}@example.com`,
    });
    const created: User = await createResponse.json();

    const response = await usersApi.getById(created.id);
    expect(response.status()).toBe(200);

    const body: User = await response.json();
    expect(body.id).toBe(created.id);
    expect(body.email).toBe(created.email);
  });

  test('PATCH /users/:id - should update the user', async ({ usersApi }) => {
    const createResponse = await usersApi.create({
      ...newUser,
      email: `update-test-${Date.now()}@example.com`,
    });
    const created: User = await createResponse.json();

    const response = await usersApi.update(created.id, { name: 'Updated Name' });
    expect(response.status()).toBe(200);

    const body: User = await response.json();
    expect(body.name).toBe('Updated Name');
  });

  test('DELETE /users/:id - should delete the user', async ({ usersApi }) => {
    const createResponse = await usersApi.create({
      ...newUser,
      email: `delete-test-${Date.now()}@example.com`,
    });
    const created: User = await createResponse.json();

    const deleteResponse = await usersApi.remove(created.id);
    expect(deleteResponse.status()).toBe(204);

    const getResponse = await usersApi.getById(created.id);
    expect(getResponse.status()).toBe(404);
  });
});
認証テスト
import { test, expect } from '@playwright/test';

test.describe('Authentication API', () => {
  test('should login with valid credentials', async ({ request }) => {
    const response = await request.post('/auth/login', {
      data: {
        email: '[email protected]',
        password: 'AdminPass123!',
      },
    });

    expect(response.status()).toBe(200);
    const body = await response.json();
    expect(body.token).toBeTruthy();
    expect(body.expiresIn).toBeGreaterThan(0);
    expect(body.user.email).toBe('[email protected]');
  });

  test('should reject invalid credentials', async ({ request }) => {
    const response = await request.post('/auth/login', {
      data: {
        email: '[email protected]',
        password: 'wrongpassword',
      },
    });

    expect(response.status()).toBe(401);
    const body = await response.json();
    expect(body.message).toBe('Invalid credentials');
  });

  test('should access protected endpoint with token', async ({ request }) => {
    // Login first
    const loginResponse = await request.post('/auth/login', {
      data: {
        email: '[email protected]',
        password: 'AdminPass123!',
      },
    });
    const { token } = await loginResponse.json();

    // Use the token
    const response = await request.get('/users/me', {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });

    expect(response.status()).toBe(200);
    const user = await response.json();
    expect(user.email).toBe('[email protected]');
  });

  test('should reject expired or invalid token', async ({ request }) => {
    const response = await request.get('/users/me', {
      headers: {
        Authorization: 'Bearer invalid.token.here',
      },
    });

    expect(response.status()).toBe(401);
  });
});
エラー処理と検証
test.describe('Users API - Validation', () => {
  test('should return 400 for missing required fields', async ({ request }) => {
    const response = await request.post('/users', {
      data: { name: 'No Email User' },
    });

    expect(response.status()).toBe(400);
    const body = await response.json();
    expect(body.details).toHaveProperty('email');
  });

  test('should return 400 for invalid email format', async ({ request }) => {
    const response = await request.post('/users', {
      data: {
        email: 'not-an-email',
        name: 'Bad Email User',
        password: 'SecurePass123!',
      },
    });

    expect(response.status()).toBe(400);
    const body = await response.json();
    expect(body.details.email).toContain('must be a valid email');
  });

  test('should return 409 for duplicate email', async ({ usersApi }) => {
    const email = `duplicate-${Date.now()}@example.com`;
    const userData = { email, name: 'First', password: 'Pass123!' };

    await usersApi.create(userData);
    const response = await usersApi.create(userData);

    expect(response.status()).toBe(409);
  });

  test('should return 404 for non-existent resource', async ({ usersApi }) => {
    const response = await usersApi.getById('non-existent-id');
    expect(response.status()).toBe(404);
  });
});
ページネーションとフィルタリング
test.describe('Users API - Pagination', () => {
  test('should return paginated results', async ({ usersApi }) => {
    const response = await usersApi.list(1, 5);

    expect(response.status()).toBe(200);
    const body = await response.json();
    expect(body.data.length).toBeLessThanOrEqual(5);
    expect(body.page).toBe(1);
    expect(body.pageSize).toBe(5);
    expect(body.total).toBeGreaterThanOrEqual(0);
  });

  test('should return correct page', async ({ usersApi }) => {
    const page1 = await (await usersApi.list(1, 2)).json();
    const page2 = await (await usersApi.list(2, 2)).json();

    const page1Ids = page1.data.map((u: { id: string }) => u.id);
    const page2Ids = page2.data.map((u: { id: string }) => u.id);
    const overlap = page1Ids.filter((id: string) => page2Ids.includes(id));
    expect(overlap).toHaveLength(0);
  });
});
レスポンスヘッダーの検証
test('should return correct response headers', async ({ request }) => {
  const response = await request.get('/users');

  expect(response.headers()['content-type']).toContain('application/json');
  expect(response.headers()['x-request-id']).toBeTruthy();
  expect(response.headers()['cache-control']).toBeDefined();

  // Security headers
  expect(response.headers()['x-content-type-options']).toBe('nosniff');
  expect(response.headers()['x-frame-options']).toBe('DENY');
});
レスポンス時間のアサーション
test('should respond within acceptable time', async ({ request }) => {
  const start = Date.now();
  const response = await request.get('/health');
  const duration = Date.now() - start;

  expect(response.status()).toBe(200);
  expect(duration).toBeLessThan(500); // 500ms threshold
});

ベストプラクティス

  1. 一意なテストデータを使う -- メールアドレスや名前にタイムスタンプまたは UUID を含め、衝突を避けます。
  2. テスト後にクリーンアップする -- テスト環境をクリーンに保つため、作成したリソースを削除します。
  3. レスポンススキーマを検証する -- 値だけでなく、レスポンスの形状も確認します。
  4. 正常系と異常系の両方をテストする -- エラーケースやエッジケースも必ずテストします。
  5. 環境変数を使う -- URL や認証情報をハードコードしないでください。
  6. テストを論理的にグループ化する -- HTTP メソッドではなく、リソースや機能ごとに整理します。
  7. 認証にはフィクスチャを使う -- 各テストでログイン処理を繰り返さないようにします。
  8. レスポンス時間を確認する -- API のパフォーマンスも正しさの一部です。
  9. 冪等性をテストする -- 同じリクエストを繰り返したとき、一貫した結果が得られることを検証します。
  10. API テストをバージョン管理する -- バージョン付き API をテストする場合は、バージョンごとにテストを整理します。

避けるべきアンチパターン

  1. テストを連鎖させる -- 各テストでそれぞれのデータを作成する必要があります。
  2. レスポンスヘッダーを無視する -- ヘッダーには重要なメタデータが含まれています。
  3. ステータスコードだけをテストする -- レスポンスボディも必ず検証します。
  4. ID をハードコードする -- ID はハードコードした値ではなく、テストのセットアップで取得します。
  5. エラーシナリオを省略する -- エラー処理のテストは、正常系のテストより多くのバグを検出します。
  6. 異なるロールでテストしない -- API の認可はロールごとにテストする必要があります。
  7. UI テストと API テストを混在させる -- API テストは E2E ブラウザーテストとは分けてください。
  8. 副作用を検証しない -- POST でリソースを作成した場合は、GET で取得して確認します。
  9. レート制限を無視する -- レート制限が適用されることをテストし、429 レスポンスを適切に処理します。
  10. 大きなペイロードでテストしない -- API が境界サイズを正しく処理できることを確認します。

高度なパターン

コンテキストを分離した並列 API テスト
test.describe.parallel('Isolated API tests', () => {
  test('test A creates and deletes user A', async ({ request }) => {
    const res = await request.post('/users', {
      data: { email: `a-${Date.now()}@test.com`, name: 'A', password: 'Pass123!' },
    });
    const user = await res.json();
    await request.delete(`/users/${user.id}`);
  });

  test('test B creates and deletes user B', async ({ request }) => {
    const res = await request.post('/users', {
      data: { email: `b-${Date.now()}@test.com`, name: 'B', password: 'Pass123!' },
    });
    const user = await res.json();
    await request.delete(`/users/${user.id}`);
  });
});
認証付きのカスタムリクエストコンテキスト
test('admin-only endpoint', async ({ playwright }) => {
  const adminContext = await playwright.request.newContext({
    baseURL: 'http://localhost:3000/api',
    extraHTTPHeaders: {
      Authorization: 'Bearer admin-token-here',
    },
  });

  const response = await adminContext.get('/admin/settings');
  expect(response.status()).toBe(200);

  await adminContext.dispose();
});
API 経由のファイルアップロード
import * as fs from 'fs';
import * as path from 'path';

test('should upload a file via API', async ({ request }) => {
  const filePath = path.resolve('test-data/sample.pdf');
  const fileBuffer = fs.readFileSync(filePath);

  const response = await request.post('/files/upload', {
    multipart: {
      file: {
        name: 'sample.pdf',
        mimeType: 'application/pdf',
        buffer: fileBuffer,
      },
      description: 'Test upload',
    },
  });

  expect(response.status()).toBe(201);
  const body = await response.json();
  expect(body.filename).toBe('sample.pdf');
  expect(body.size).toBeGreaterThan(0);
});

コメント