test-api
Skill1 starsChanged 9 days agoMITView on GitHub
Playwright API testing best practices. Use when writing REST API tests with Playwright. Enforces Zod schema validation, client/fixture separation, and contract testing.
In this repository41 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- analyze-codeSkill
- analyze-projectSkill
- analyze-testsSkill
- audit-mergesSkill
- babysit-prSkill
- backlogSkill
- branchSkill
- coderabbitSkill
- commitSkill
- greptileSkill
- implement-issuesSkill
- issueSkill
- jiraSkill
- lintro-addSkill
- lintro-reviewSkill
- lintro-verifySkill
- lintSkill
- properizeSkill
- pr-raycastSkill
- prSkill
- raycastSkill
- rebaseSkill
- reconcileSkill
- scorecardSkill
- stand-ciSkill
- stand-generalSkill
- stand-odinSkill
- stand-pySkill
- stand-rustSkill
- stand-tsSkill
- sweep-prsSkill
- test-shellSkill
- testSkill
- test-ui-qsfSkill
- test-uiSkill
- turbo-addSkill
- turbo-testSkill
- turbo-verifySkill
- which-prSkill
The file
We split this skill into the rules it gives an agent
3 rules. About 0 are common, 2 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 8
Write API tests using Playwright's built-in HTTP client.
- Very specific: line 56
Use schema.parse() for runtime validation, not just type assertions:
- Distinctive: line 133
Check everything in one assertion:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Playwright API Testing
Write API tests using Playwright's built-in HTTP client.
Project Structure
tests/
├── 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 utilitiesSchema Validation with Zod
Use z.strictObject() for Contract Testing
Catches unexpected extra properties from the API:
// 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({ ... });Derive Types from Schemas
Single source of truth - never maintain separate type definitions:
// 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.tsActually Validate Responses
Use schema.parse() for runtime validation, not just type assertions:
// 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();Layer Separation
Client Layer (HTTP logic)
// 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 (lifecycle + wiring)
Fixtures delegate to clients and handle cleanup:
// 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(() => {});
}
},
});Test Patterns
Error Response Testing
Check everything in one assertion:
// 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);Parameterized Tests
Playwright lacks native test.each(). Use a for-loop:
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);
});
}Or wrap in a helper if used frequently:
function testEach<T>(
cases: { name: string; data: T }[],
fn: (data: T) => Promise<void>,
) {
for (const { name, data } of cases) {
test(name, () => fn(data));
}
}Security Tests
Include basic security validation:
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
Centralize test data:
// 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;Checklist
- Schemas use
z.strictObject()for contract testing - Types derived with
z.infer<>(no separate type files) - Responses validated with
schema.parse()at runtime - HTTP logic in client files, fixtures handle lifecycle only
- Base paths centralized (not hardcoded everywhere)
- Error cases use
toMatchObjectfor single-assertion checks - Security edge cases covered (injection, malformed input, size limits)
- Test data in constants (not magic strings in tests)
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

