API testing patterns for Playwright + TypeScript — resource class pattern (HTTP wrappers extending BasePage, domain folders mirroring REST namespaces, typed payload builders), the ApiListener pattern for capturing real responses by stateKey without mocking, optional SQL/stored-procedure bridge for test-data setup, and TOTP-based MFA enrollment. Use when seeding test data via API, asserting on API responses without mocking, building HTTP wrapper classes, capturing network responses during UI tests, or setting up test users with MFA.
How this skill is triggered — by the user, by Claude, or both
Slash command
/pw-kit:api-testingWhen to use
Trigger phrases: "API test", "API helper", "api-listener", "ApiListener", "page.request", "capture response", "API resource class", "seed via API", "MFA", "TOTP", "executeLocalProcedure", "SQL setup", "test data via API". Auto-activates on files in api/.
api/**/*.tstests/**/*.spec.tspages/**/*.tsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Apply these patterns when seeding test data via API, asserting on real API responses without mocking, or building reusable HTTP wrappers for tests.
Apply these patterns when seeding test data via API, asserting on real API responses without mocking, or building reusable HTTP wrappers for tests.
Wrap each backend resource in a class. The folder structure mirrors the backend's REST namespace, so api/CRM/Users/... endpoints live in api/crm/users/users-resource.ts. Each class extends BasePage to inherit the same page.request context and credentials.
// api/crm/users/users-resource.ts
import { APIResponse, expect } from '@playwright/test';
import { BasePage } from '@POM/base-page';
interface UsersSearchParams {
type: string;
q?: string;
status?: 'active' | 'inactive';
page?: string;
pageSize?: string;
}
export class UsersResource extends BasePage {
async getUsersSearch(params: UsersSearchParams): Promise<APIResponse> {
const url = new URL(`${this.BASE_URL}/api/CRM/Users/Search`);
Object.entries(params)
.filter(([_, v]) => v != null && v !== '')
.forEach(([k, v]) => url.searchParams.set(k, String(v)));
const response = await this.page.request.get(url.toString());
expect(response.status()).toBe(200);
return response;
}
async createUser(payload: { email: string; firstName: string; lastName: string }): Promise<APIResponse> {
const response = await this.page.request.post(`${this.BASE_URL}/api/CRM/Users`, { data: payload });
expect(response.status()).toBe(201);
return response;
}
}
this.page so it shares the authenticated session.this.BASE_URL, etc.).expect import.base-pages-fixture.ts like any other POM.Use a TypeScript interface with optional fields, then filter undefined/empty before building the query string. Compile-time guidance on which params are valid, runtime flexibility to omit any.
Inline expect(response.status()).toBe(200) ensures API failures surface immediately at the seeding step, not as a confusing UI assertion later.
If you may need to run helpers against multiple server instances, accept an optional baseUrl parameter that defaults to this.BASE_URL:
async getUsersSearch(params: UsersSearchParams, baseUrl?: string): Promise<APIResponse> {
const url = new URL(`${baseUrl ?? this.BASE_URL}/api/CRM/Users/Search`);
// ...
}
api/
├── api-listener.ts # cross-cutting network intercept
├── <domain>/ # admin, crm, finance, security, txn, web
│ └── <entity>-resource.ts # CRUD wrappers (read-heavy)
│ └── <entity>-operations.ts # CRUD wrappers (mutation-heavy)
├── custom/ # test-infra: stored-proc bridge, SQL passthrough
└── integrations/ # third-party endpoints (separate from domain)
The convention:
*-resource.ts for read-heavy collections (GET-dominant)*-operations.ts for write-heavy entities (POST/PUT/DELETE-dominant)custom/ for test-infra helpers that wrap procedure or SQL executionintegrations/ for third-party service wrappers (payment gateways, identity providers, analytics)Each domain folder corresponds to a top-level REST namespace. New domains get a new folder.
Set up listeners before navigation, then trigger a UI action and read the captured response after. This avoids mocking entirely — you get structured access to real backend data for assertions.
// api/api-listener.ts (sketch — full implementation in references/api-listener-template.ts)
export class ApiListener {
constructor(private page: Page) {
this.capturedResponses = {};
this.capturedRequests = {};
this.routesToIntercept = [];
}
addRouteCapture(regex: RegExp, capture: { method: string; stateKey: string; responseTransformer?: (data: any) => any }): void;
async collectDataFromAPICalls(): Promise<void>;
async waitForAPIResponses(stateKeys: string[]): Promise<void>;
getCapturedData(stateKey: string): any;
getCapturedRequestInfo(stateKey: string): CapturedRequestInfo[];
async cleanup(): Promise<void>;
}
Register routes before the UI action; data is captured as it flows in:
const apiListener = new ApiListener(usersPage.page);
apiListener.addRouteCapture(/\/api\/CRM\/Users\/Search\?type=fluent/, {
method: 'GET',
stateKey: 'usersSearch'
});
await apiListener.collectDataFromAPICalls();
await usersPage.searchButton.click(); // triggers the API call
await usersPage.waitForSpinnerToDisappear();
const results = apiListener.getCapturedData('usersSearch');
expect(results.users).toHaveLength(5);
expect(results.users[0].id).toBe(42);
Wait for a specific response without blocking the UI action:
const apiPromise = apiListener.waitForAPIResponses(['usersSearch']);
await Promise.all([
usersPage.searchButton.click(),
apiPromise
]);
const results = apiListener.getCapturedData('usersSearch');
stateKey ties setup to assertion site by name. No tight coupling between intercept code and assertion code.Mock only external/third-party services (payment gateways, third-party identity providers, analytics endpoints). Never mock the primary application API — that defeats the purpose of end-to-end testing.
await page.route('**/api.stripe.com/v1/charges', route => route.fulfill({
status: 200,
body: JSON.stringify({ id: 'ch_test', status: 'succeeded' })
}));
If the application exposes a generic procedure-execution endpoint, wrap it for test-only DB manipulation. This avoids needing a direct DB connection from tests.
// api/custom/execute-local-procedure.ts (sketch)
export class ExecuteLocalProcedure extends BasePage {
async executeLocalProcedure(
payload: { procedureId: string; procedureName: string; parameterValues: Array<{ name: string; value: string }> }
): Promise<any>;
async runSqlQuery(sql: string): Promise<any>;
async createUserForTest(userId: string, fname: string, lname: string, password: string): Promise<void>;
async assignUserToGroup(userId: string, groupId: string): Promise<void>;
async getSortedSeasons(): Promise<string[]>;
async verifySeasonDropdownSorted(dropdown: Locator): Promise<void>;
// ... domain-specific helpers built atop the bridge
}
addProcedureAndGetId(name) before using.For tests against MFA-enabled users, generate TOTP codes inline:
// api/security/mfa-resource.ts (sketch)
export class MfaResource extends BasePage {
async registerUserForMfa(opts: { userId: string; password: string }): Promise<Authenticator>;
}
// Returned Authenticator instance can generate codes
const authenticator = await mfaResource.registerUserForMfa({ userId, password });
const code = authenticator.generateCode();
await loginPage.mfaInput.fill(code);
The Authenticator is a self-contained RFC 6238 TOTP generator (no external library) initialized with the secret from the MFA setup API. Reuse the instance for subsequent code generations within the same 30-second window.
For tests needing fresh user data:
// api/web/registration/registration-resource.ts (sketch)
export class RegistrationResource {
async getRandomUser(type?: 'Individual' | 'Household'): Promise<{
newUserResponse: any;
registrationBodyResponse: any;
}>;
}
const { newUserResponse } = await registrationResource.getRandomUser('Individual');
test('PREFIX-NNNNN: User filters by status', async ({
usersPage, common, executeLocalProcedure
}) => {
let cleanupNeeded = false;
const userId = common.randomNumber(7);
try {
await test.step('Given an active user exists', async () => {
await executeLocalProcedure.createUserForTest(String(userId), 'Test', 'User', 'pass123');
cleanupNeeded = true;
});
await test.step('When User filters by Active status', async () => {
await usersPage.filterByStatus('Active');
});
await test.step('Then the test user is visible', async () => {
await usersPage.verifyExists(`Test User`);
});
} finally {
if (cleanupNeeded) {
await test.step('Cleanup', async () => {
await executeLocalProcedure.runSqlQuery(`DELETE FROM users WHERE id = ${userId}`);
});
}
}
});
The API helpers handle setup and cleanup; the test body focuses on the UI behavior under test.
| Anti-pattern | Fix |
|---|---|
| Mocking the primary application API | Use ApiListener to capture real responses; mock only external/third-party services |
Hardcoding the BASE_URL in resource methods | Use this.BASE_URL (inherited from BasePage) or accept optional override |
Skipping the inline expect(response.status()) check | Always assert status at the call site so failures surface where they happen |
Creating a fresh request.newContext() inside each method | Use the inherited this.page.request — shares cookies and auth |
| Bypassing the UI for the action under test | API is for setup and cleanup only; actions under test happen through the UI |
| Hardcoding test data IDs that exist in one environment | Either precreate in DB-as-code project, or seed via API |
Setting up page.route() listeners inside the test | Move into the ApiListener class or a fixture; tests just call getCapturedData(key) |
Additional reference docs (recipes, deep-dives, edge cases) will be added based on team feedback. PRs welcome.
Guides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.
npx claudepluginhub antongulin/pw-kit --plugin pw-kit