Loading...
Loading...
Playwright API testing best practices. Use when writing REST API tests with Playwright. Enforces Zod schema validation, client/fixture separation, and contract testing.
npx skill4agent add lgtm-hq/ai-skills test-apitests/
├── specs/ # Test files organized by feature/domain
├── fixtures/ # Playwright fixtures (lifecycle + wiring)
├── clients/ # HTTP client logic (endpoints, methods)
├── schemas/ # Zod schemas (source of truth for types)
├── constants/ # Test data, business rules
└── utils/ # Shared utilitiesz.strictObject()// GOOD: Fails if API returns extra fields
export const UserSchema = z.strictObject({
id: z.string().uuid(),
email: z.string().email(),
createdAt: z.string(),
});
// BAD: Silently ignores extra fields
export const UserSchema = z.object({ ... });// GOOD: Types derived from schemas
export const UserSchema = z.strictObject({ ... });
export type User = z.infer<typeof UserSchema>;
// BAD: Duplicate definitions that can drift apart
interface User { ... } // in types.ts
const UserSchema = z.object({ ... }) // in schemas.tsschema.parse()// GOOD: Runtime validation
const body = await response.json();
const user = UserResponseSchema.parse(body);
// BAD: Type assertion only (no runtime check)
const body: ApiResponse<User> = await response.json();// clients/user.client.ts
const BASE_PATH = "/api/users";
function userPath(...segments: string[]): string {
return [BASE_PATH, ...segments].join("/");
}
export async function createUser(
request: APIRequestContext,
data: CreateUserInput,
): Promise<User> {
const response = await request.post(userPath(), { data });
return UserResponseSchema.parse(await response.json()).data;
}
// Raw variant for error testing (no validation, returns raw response)
export function createUserRaw(
request: APIRequestContext,
data: unknown,
): Promise<APIResponse> {
return request.post(userPath(), { data });
}// fixtures/user.fixture.ts
import * as userClient from "../clients/user.client";
export const test = base.extend<UserFixtures>({
userApi: async ({ request }, use) => {
const created = new Set<string>();
const api = {
create: async (data: CreateUserInput) => {
const user = await userClient.createUser(request, data);
created.add(user.id);
return user;
},
createRaw: (data: unknown) => userClient.createUserRaw(request, data),
delete: (id: string) => userClient.deleteUser(request, id),
// ...
};
await use(api);
// Cleanup
for (const id of created) {
await userClient.deleteUser(request, id).catch(() => {});
}
},
});// GOOD: Single assertion checks all properties
const body = await response.json();
expect(body).toMatchObject({
success: false,
error: expect.stringMatching(/not found/i),
});
// AVOID: Sequential assertions stop on first failure
expect(body.success).toBe(false);
expect(body.error).toMatch(/not found/i);test.each()const cases = [
{ name: "empty string", input: "", status: 400 },
{ name: "too long", input: "x".repeat(256), status: 400 },
{ name: "valid", input: "test@example.com", status: 201 },
];
for (const { name, input, status } of cases) {
test(`email validation: ${name}`, async ({ userApi }) => {
const response = await userApi.createRaw({ email: input });
expect(response.status()).toBe(status);
});
}function testEach<T>(
cases: { name: string; data: T }[],
fn: (data: T) => Promise<void>,
) {
for (const { name, data } of cases) {
test(name, () => fn(data));
}
}test.describe("Security", () => {
test("SQL injection in ID parameter", async ({ request }) => {
const response = await request.get("/api/users/1 OR 1=1");
expect([400, 404]).toContain(response.status());
});
test("rejects oversized payload", async ({ request }) => {
const response = await request.post("/api/users", {
data: { name: "x".repeat(1_000_000) },
});
expect(response.status()).toBe(413);
});
});// constants/test-data.ts
export const TEST_USERS = {
VALID: { email: "test@example.com", name: "Test User" },
ADMIN: { email: "admin@example.com", name: "Admin", role: "admin" },
} as const;
// constants/business-rules.ts
export const LIMITS = {
MAX_NAME_LENGTH: 255,
MAX_ITEMS_PER_PAGE: 100,
} as const;z.strictObject()z.infer<>schema.parse()toMatchObject