SuperAPI AI Directory

api-testing

Coding & development

View repository

Updated 10-15 16:51

About this skill

API Testing Patterns

Apply these patterns when seeding test data via API, asserting on real API responses without mocking, or building reusable HTTP wrappers for tests.

The Resource Class Pattern

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 { 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 { const response = await this.page.request.post(${this.BASE_URL}/api/CRM/Users, { data: payload }); expect(response.status()).toBe(201); return response; } }


### Why extends BasePage

- Same `this.page` so it shares the authenticated session.
- Same env-backed credentials (`this.BASE_URL`, etc.).
- Same `expect` import.
- Same fixture lifecycle — instantiated via `base-pages-fixture.ts` like any other POM.

### Typed params with optional fields

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.

### Status assertion at the call site

Inline `expect(response.status()).toBe(200)` ensures API failures surface immediately at the seeding step, not as a confusing UI assertion later.

### Optional baseUrl per method

If you may need to run helpers against multiple server instances, accept an optional `baseUrl` parameter that defaults to `this.BASE_URL`:

```ts
async getUsersSearch(params: UsersSearchParams, baseUrl?: string): Promise<APIResponse> {
  const url = new URL(`${baseUrl ?? this.BASE_URL}/api/CRM/Users/Search`);
  // ...
}

File Layout

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 execution
  • integrations/ 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.

The ApiListener Pattern — Capture Real Responses

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>;
}

Usage pattern 1 — pre-intercept

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);

Usage pattern 2 — promise race

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');

Why this beats mocking

  • Real responses, not synthetic — tests catch real backend regressions.
  • Decoupled — stateKey ties setup to assertion site by name. No tight coupling between intercept code and assertion code.
  • Transparent — the original response is re-fulfilled, so the app continues normally. The listener is invisible to the UI under test.

When mocking IS appropriate

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' })
}));

The SQL / Stored-Procedure Bridge (optional, project-specific)

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
}

Key patterns

  • One bridge endpoint + one SQL passthrough procedure handles every test-infra DB need.
  • Idempotent procedure registration: if a stored procedure may or may not exist, check first via addProcedureAndGetId(name) before using.
  • DB-truth assertions: for asserting sort order or filter results, query the DB for the expected output and compare against the UI, rather than hardcoding the expected list. Adapts automatically when reference data changes.

Caveats

  • Test environments only. A raw SQL passthrough is a security risk in production.
  • Many backends don't have this. Skip the bridge entirely if your backend doesn't expose a procedure-execution endpoint; use direct database access via a Node.js DB client in a fixture instead.

MFA / TOTP Pattern

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.

Faker for Random Test Users

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');
  • Faker properties generated fresh per call (not cached on the class).
  • Returns BOTH the response JSON AND the original request body — useful when assertions need to know what was sent.

How API Helpers Compose With Tests

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.

Common Anti-Patterns

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)

Further Reading

Additional reference docs (recipes, deep-dives, edge cases) will be added based on team feedback. PRs welcome.