mcpField
P
Playwright API Testing

Playwright API Testing

github.com/pramoddutta测试与质量24461 次安装1 周前更新
代码仓库

关于 Playwright API Testing

Playwright API Testing 是一项使用 Playwright 编写、审查和调试 API 测试的技能,面向 QA 自动化工程师。它侧重类型安全、测试隔离、完整响应校验和可复用的 API 客户端。

主要功能

  1. 01使用 Playwright APIRequestContext
  2. 02为请求和响应定义类型
  3. 03独立管理测试数据生命周期
  4. 04校验状态码、响应体、标头和耗时
  5. 05按服务领域复用 API 客户端
  6. 06覆盖认证、分页和错误场景

适用场景

  1. 01为 CRUD 接口编写并验证 API 测试
  2. 02检查认证、权限和错误处理行为
  3. 03测试分页、筛选及响应标头
  4. 04验证 API 性能与文件上传

安装

复制下面的命令,在终端中运行。

$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 测试——使用 APIRequestContext,而不是外部 HTTP 库。
  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 });
  }
}

自定义 Fixture

// 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. 使用 Fixture 进行身份验证——避免在每项测试中重复登录逻辑。
  8. 检查响应时间——API 性能也是正确性的一部分。
  9. 测试幂等性——验证重复的相同请求是否会产生一致结果。
  10. 对 API 测试进行版本管理——测试带版本的 API 时,按版本组织测试。

应避免的反模式

  1. 测试之间存在依赖——每项测试都必须创建自己的数据。
  2. 忽略响应标头——标头携带重要的元数据。
  3. 只测试状态码——还要始终验证响应正文。
  4. 使用硬编码 ID——ID 应来自测试设置,而不是硬编码值。
  5. 跳过错误场景——与成功路径测试相比,错误处理测试能发现更多 bug。
  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);
});

评论

支持 Markdown