API Testing Project 1: User Directory Suite with Playwright TypeScript

API Testing Project 1: User Directory Suite with Playwright TypeScript

This is your first complete, portfolio-ready API automation project. You’ll build a Playwright + TypeScript suite for a user directory API, the kind behind every admin panel, CRM and employee portal. It covers pagination, filtering, sorting, search, single-record reads, nested resources and negative cases. The final suite has 34 tests that pass against the live SQA Tools practice API, and every line of code below is the exact code that ran.

Project summaryLevel: beginner. Time: 3 to 4 hours. API: GET /users, GET /users/{id}, GET /users/{id}/orders. You’ll practise: a typed API client, Zod schema validation, fixtures, data-driven negative tests, pagination walking, set-based checks for filters and stable sorting checks. Result: 34 passing tests in about 12 seconds.
Advertisement

The Real-Life Scenario

An HR portal’s employee directory HR staff search employees by name, filter by role and city, sort by age or joining date, and page through results. Mobile apps load 5 at a time; the admin web page loads 50. Bugs here are common and embarrassing: the last page repeats an employee, a filter ignores case, “sort by age” silently sorts by id on page 2, or a typo in the URL crashes the server with a 500.

The practice API’s /users resource behaves exactly like such a directory: 12 users, roles admin/editor/viewer, cities, an active flag, 5 per page by default and a maximum page size of 50.

What You’ll Build

Spec fileTestsCovers
pagination.spec.ts12First/middle/last/beyond pages, walking all pages, total_pages maths, headers vs body, 5 invalid parameter cases
filter-sort-search.spec.ts13Role, city (case-insensitive), boolean and combined filters, filters partition the data, sort asc/desc, sort across pages, invalid sort, search incl. special characters
single-user.spec.ts9Schema-validated single user, list vs detail consistency, 5 invalid ids, nested orders and totals, response time

Project Structure

user-directory-api-tests/
├── package.json
├── playwright.config.ts
├── src/
│   ├── users-api.ts        # typed client + Zod schemas
│   └── fixtures.ts         # injects the client into tests
└── tests/
    ├── pagination.spec.ts
    ├── filter-sort-search.spec.ts
    └── single-user.spec.ts

Setup

mkdir user-directory-api-tests && cd user-directory-api-tests
npm init -y
npm install -D @playwright/test typescript @types/node zod
git init

No browsers are needed: the suite only makes HTTP calls.

Advertisement

Step 1: Configuration

playwright.config.ts

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 1 : 0,
  reporter: [['list'], ['html', { open: 'never' }]],
  use: {
    baseURL: process.env.API_BASE_URL ?? 'https://sqatools.in/wp-json/sqa-api/v1/',
    extraHTTPHeaders: { Accept: 'application/json' },
  },
});

The trailing slash in baseURL is essential; the APIRequestContext lesson explains why. API_BASE_URL lets you point the same suite at another environment.

Step 2: A Typed Client with Schemas

The client is the heart of the project. Two design decisions make the tests short and strict:

  • page() validates every response with Zod. Any missing field, wrong type or extra field fails immediately with a precise message, so every test is also a contract test.
  • all() walks every page, so filter tests check all matching users, not just the first page.

src/users-api.ts

import { APIRequestContext, APIResponse } from '@playwright/test';
import { z } from 'zod';

export const User = z.object({
  id: z.number().int().positive(),
  name: z.string(),
  username: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'editor', 'viewer']),
  city: z.string(),
  age: z.number().int().min(18),
  active: z.boolean(),
  created_at: z.string().datetime(),
}).strict();
export type User = z.infer<typeof User>;

export const UserPage = z.object({
  data: z.array(User),
  meta: z.object({
    page: z.number(), per_page: z.number(), total: z.number(), total_pages: z.number(),
    sort: z.string(), order: z.enum(['asc', 'desc']),
  }),
  links: z.object({
    self: z.string().url(), first: z.string().url(), last: z.string().url(),
    prev: z.string().url().nullable(), next: z.string().url().nullable(),
  }),
});
export type UserPage = z.infer<typeof UserPage>;

export type UserQuery = Partial<{
  page: number | string; per_page: number | string; role: string; city: string;
  active: boolean | string; q: string; sort: string; order: string;
}>;

/** Thin client for the /users resource. */
export class UsersApi {
  constructor(private readonly request: APIRequestContext) {}

  list(query: UserQuery = {}): Promise<APIResponse> {
    return this.request.get('users', { params: query as Record<string, string | number | boolean> });
  }

  /** Parsed and schema-validated page; throws with a readable message if the contract breaks. */
  async page(query: UserQuery = {}): Promise<UserPage> {
    const res = await this.list(query);
    if (!res.ok()) throw new Error(`GET users ${JSON.stringify(query)} -> ${res.status()} ${await res.text()}`);
    return UserPage.parse(await res.json());
  }

  /** Walks every page and returns all users. */
  async all(per_page = 5, query: UserQuery = {}): Promise<User[]> {
    const users: User[] = [];
    for (let page = 1; page <= 100; page++) {
      const { data } = await this.page({ ...query, page, per_page });
      if (data.length === 0) break;
      users.push(...data);
    }
    return users;
  }

  get(id: number | string): Promise<APIResponse> {
    return this.request.get(`users/${id}`);
  }

  orders(id: number | string): Promise<APIResponse> {
    return this.request.get(`users/${id}/orders`);
  }
}

Notice .strict() on the user schema: if the API ever starts returning a field like password_hash, every test that reads users fails. That’s how you catch data leaks automatically.

Step 3: A Fixture

src/fixtures.ts

import { test as base, expect } from '@playwright/test';
import { UsersApi } from './users-api';

export const test = base.extend<{ users: UsersApi }>({
  users: async ({ request }, use) => { await use(new UsersApi(request)); },
});
export { expect };

Tests now ask for users and get a ready client, with no setup code repeated in every file.

Advertisement

Step 4: Pagination Tests

tests/pagination.spec.ts

import { test, expect } from '../src/fixtures';

test.describe('Pagination', () => {
  test('default page: 5 users, page 1, prev link is null', async ({ users }) => {
    const res = await users.page();
    expect(res.data).toHaveLength(5);
    expect(res.meta).toMatchObject({ page: 1, per_page: 5, total: 12, total_pages: 3 });
    expect(res.links.prev).toBeNull();
    expect(res.links.next).toContain('page=2');
  });

  test('page 2 starts at the 6th record', async ({ users }) => {
    const { data } = await users.page({ page: 2, per_page: 5 });
    expect(data.map(u => u.id)).toEqual([6, 7, 8, 9, 10]);
  });

  test('last page holds the remainder and has no next link', async ({ users }) => {
    const res = await users.page({ page: 3, per_page: 5 });
    expect(res.data.map(u => u.id)).toEqual([11, 12]);
    expect(res.links.next).toBeNull();
  });

  test('page beyond the end is an empty list, not an error', async ({ users }) => {
    const res = await users.page({ page: 10 });
    expect(res.data).toEqual([]);
    expect(res.meta.total).toBe(12);
  });

  test('walking every page returns 12 unique users in id order', async ({ users }) => {
    const all = await users.all(5);
    expect(all.map(u => u.id)).toEqual(Array.from({ length: 12 }, (_, i) => i + 1));
  });

  test('total_pages matches ceil(total / per_page) for several sizes', async ({ users }) => {
    for (const per_page of [1, 4, 5, 7, 12, 50]) {
      const { meta, data } = await users.page({ per_page });
      expect(meta.total_pages, `per_page=${per_page}`).toBe(Math.ceil(12 / per_page));
      expect(data.length).toBe(Math.min(per_page, 12));
    }
  });

  test('pagination headers agree with the body', async ({ users }) => {
    const res = await users.list({ per_page: 5 });
    const body = await res.json();
    expect(res.headers()['x-total-count']).toBe(String(body.meta.total));
    expect(res.headers()['x-total-pages']).toBe(String(body.meta.total_pages));
    expect(res.headers()['link']).toContain('rel="next"');
  });

  for (const [query, label] of [
    [{ page: 0 }, 'page=0'], [{ page: -1 }, 'page=-1'], [{ page: 'two' }, 'page=two'],
    [{ per_page: 0 }, 'per_page=0'], [{ per_page: 51 }, 'per_page=51'],
  ] as const) {
    test(`invalid ${label} -> 400 with a field error`, async ({ users }) => {
      const res = await users.list(query);
      expect(res.status()).toBe(400);
      const field = Object.keys(query)[0];
      expect((await res.json()).error.details[0].field).toBe(field);
    });
  }
});

Highlights:

  • Walking every page and comparing with [1..12] catches the classic pagination bugs: duplicates, gaps and wrong order in one assertion.
  • Several page sizes in one test verify the total_pages formula across the edges (1, 5, 7, 12, 50).
  • Headers vs body: X-Total-Count and meta.total must agree, since different clients read different ones.
  • Data-driven negatives also check the error names the right field.

tests/filter-sort-search.spec.ts

import { test, expect } from '../src/fixtures';
import type { User } from '../src/users-api';

test.describe('Filtering', () => {
  test('role=admin returns only admins', async ({ users }) => {
    const all = await users.all(50, { role: 'admin' });
    expect(all.length).toBeGreaterThan(0);
    for (const u of all) expect(u.role).toBe('admin');
  });

  test('city filter is case-insensitive', async ({ users }) => {
    const lower = await users.all(50, { city: 'pune' });
    const proper = await users.all(50, { city: 'Pune' });
    expect(lower.map(u => u.id)).toEqual(proper.map(u => u.id));
    for (const u of lower) expect(u.city).toBe('Pune');
  });

  test('active=false returns only inactive users', async ({ users }) => {
    const inactive = await users.all(50, { active: false });
    expect(inactive.length).toBeGreaterThan(0);
    for (const u of inactive) expect(u.active).toBe(false);
  });

  test('filters combine with AND', async ({ users }) => {
    const both = await users.all(50, { role: 'admin', city: 'Chennai' });
    for (const u of both) expect(u).toMatchObject({ role: 'admin', city: 'Chennai' });
  });

  test('every user appears in exactly one role filter', async ({ users }) => {
    const all = await users.all(50);
    const byRole = await Promise.all(['admin', 'editor', 'viewer'].map(role => users.all(50, { role })));
    const ids = byRole.flat().map(u => u.id).sort((a, b) => a - b);
    expect(ids).toEqual(all.map(u => u.id));
  });

  test('invalid boolean is rejected', async ({ users }) => {
    expect((await users.list({ active: 'maybe' })).status()).toBe(400);
  });
});

test.describe('Sorting', () => {
  const isSorted = (values: number[], order: 'asc' | 'desc') =>
    values.every((v, i) => i === 0 || (order === 'asc' ? values[i - 1] <= v : values[i - 1] >= v));

  for (const order of ['asc', 'desc'] as const) {
    test(`sort by age ${order}`, async ({ users }) => {
      const { data, meta } = await users.page({ sort: 'age', order, per_page: 50 });
      expect(meta).toMatchObject({ sort: 'age', order });
      expect(isSorted(data.map((u: User) => u.age), order)).toBe(true);
    });
  }

  test('sorting is consistent across pages', async ({ users }) => {
    const paged = await users.all(4, { sort: 'age', order: 'desc' });
    const single = (await users.page({ sort: 'age', order: 'desc', per_page: 50 })).data;
    expect(paged.map(u => u.id)).toEqual(single.map(u => u.id));
  });

  test('unknown sort field and invalid order are rejected', async ({ users }) => {
    expect((await users.list({ sort: 'salary' })).status()).toBe(400);
    expect((await users.list({ order: 'up' })).status()).toBe(400);
  });
});

test.describe('Search', () => {
  test('q=gupta finds Manish Gupta', async ({ users }) => {
    const { data } = await users.page({ q: 'gupta' });
    expect(data.map(u => u.name)).toContain('Manish Gupta');
  });

  test('no match is 200 with an empty list', async ({ users }) => {
    const { data, meta } = await users.page({ q: 'zzz' });
    expect(data).toEqual([]);
    expect(meta.total).toBe(0);
  });

  test('special characters do not break search', async ({ users }) => {
    for (const q of ["O'Brien", '%', '<script>', 'आशा', '😀']) {
      const res = await users.list({ q });
      expect(res.status(), `q=${q}`).toBe(200);
    }
  });
});

Two tests worth stealing for your own projects:

  • “Every user appears in exactly one role filter”: the union of the three role filters must equal the full list. It catches users with a missing or unknown role, and filters that leak or drop records, without hardcoding any data.
  • “Sorting is consistent across pages”: sorting page by page (4 per page) must give the same order as one big page. Many APIs sort only within a page, which breaks infinite-scroll lists.
Advertisement

Step 6: Single User and Nested Orders

tests/single-user.spec.ts

import { test, expect } from '../src/fixtures';
import { User } from '../src/users-api';

test.describe('Single user and nested orders', () => {
  test('GET users/1 returns Asha Verma matching the schema', async ({ users }) => {
    const res = await users.get(1);
    await expect(res).toBeOK();
    const { data } = await res.json();
    expect(User.parse(data)).toMatchObject({ id: 1, name: 'Asha Verma', role: 'admin', city: 'Pune' });
  });

  test('every user from the list is readable individually with identical data', async ({ users }) => {
    for (const u of await users.all(50)) {
      const { data } = await (await users.get(u.id)).json();
      expect(data, `user ${u.id}`).toEqual(u);
    }
  });

  for (const [id, status] of [[999, 404], [0, 400], [-1, 400], ['abc', 400], ['1.5', 400]] as const) {
    test(`GET users/${id} -> ${status}`, async ({ users }) => {
      const res = await users.get(id);
      expect(res.status()).toBe(status);
      const body = await res.json();
      expect(body.error).toMatchObject({ status, code: expect.any(String), message: expect.any(String) });
    });
  }

  test('nested orders belong to the user and totals add up', async ({ users }) => {
    const { data: orders } = await (await users.orders(1)).json();
    expect(orders.length).toBeGreaterThan(0);
    for (const o of orders) {
      expect(o.user_id).toBe(1);
      const subtotal = o.items.reduce((sum: number, i: { line_total: number }) => sum + i.line_total, 0);
      expect(o.summary.subtotal).toBe(subtotal);
      expect(o.summary.total).toBeCloseTo(subtotal + o.summary.tax, 2);
    }
  });

  test('list endpoint answers within 3 seconds', async ({ users }) => {
    const start = Date.now();
    await users.list();
    expect(Date.now() - start).toBeLessThan(3_000);
  });
});

The list-vs-detail test reads all 12 users from the list and then each one individually, expecting identical objects. It’s a simple idea that finds real bugs, such as a detail endpoint using a different date format or a stale cache.

Run It

npx playwright test
npx playwright show-report

The real result against the live practice API:

Running 34 tests using 6 workers
  34 passed (11.6s)

Bugs This Suite Would Catch

BugCaught by
Last page repeats or skips a user“walking every page returns 12 unique users in id order”
Off-by-one in total_pages“total_pages matches ceil(total / per_page)”
City filter case-sensitive“city filter is case-insensitive”
Sort applied per page only“sorting is consistent across pages”
New sensitive field in responsesZod .strict() in every test
500 on invalid inputThe data-driven 400 tests
Detail and list disagree“every user from the list is readable individually”
Wrong order totals for a user“nested orders belong to the user and totals add up”
Advertisement

Extend the Project (Assignments)

  1. Add a search + filter + sort + pagination combination test.
  2. Add tests for Accept: application/xml on /users/1 and an unsupported Accept (406).
  3. Write a @smoke tag on 5 critical tests and run them with --grep @smoke.
  4. Add a GitHub Actions workflow (see the CI lesson).
  5. Replace all()‘s page loop with one that follows the links.next URL instead of counting pages.

Make It Portfolio-Ready

Push it to GitHub with a README that recruiters can scan in 30 seconds:

# User Directory API Tests (Playwright + TypeScript)

34 automated API tests for a paginated user directory (pagination, filtering,
sorting, search, nested resources, negative cases).

**Stack:** Playwright Test, TypeScript, Zod schema validation
**Run:** `npm ci && npx playwright test` (about 12 s, no browser needed)

## Highlights
- Every response validated against a strict Zod schema (catches data leaks)
- Data-driven negative tests with field-level error checks
- Pagination walked end-to-end to detect duplicates and gaps

In interviews, describe one test you’re proud of and the bug it would catch. That’s far more convincing than a test count.

Advertisement

Interview Questions About This Project

Walk me through your API automation project.

Explain the domain (a paginated user directory), the stack (Playwright, TypeScript, Zod), the structure (typed client, fixture, three spec files), the coverage (pagination, filters, sorting, search, negative cases, nested orders) and one interesting test, such as walking every page to detect duplicates and gaps.

How did you test pagination?

First, middle, last and out-of-range pages; page size boundaries; total_pages for several page sizes; headers against the body; invalid values; and a test that walks every page and compares the collected ids with the full expected list to detect duplicates, gaps and order problems.

How do you verify a filter without hardcoding expected data?

Assert a property on every returned item (every user has role admin), and use set-based checks, such as the union of all role filters equalling the full list, which works even if the data changes.

Why validate every response with a schema?

It turns every functional test into a contract test: missing fields, wrong types and unexpected extra fields (including leaked sensitive data) are caught immediately with a precise message.

How did you test sorting?

Checked ascending and descending order of the sorted field, that meta reports the sort, that unknown fields and invalid orders are rejected, and that paginated sorting matches a single large page.

Advertisement
Why use a client class instead of calling request.get directly?

It hides URLs and parameters behind typed methods, adds schema validation and helpers like walking all pages in one place, and keeps tests short and focused on behaviour.

How do you keep the suite fast?

Pure API tests without browsers, parallel workers, independent tests and reading data in large pages where the page size isn’t under test. The whole suite runs in about 12 seconds.

What would you add next?

Combined search/filter/sort/pagination tests, content negotiation tests, tags for smoke runs, CI with reports, and following links.next instead of counting pages.

How do you handle data that changes over time?

Prefer property and set-based assertions over hardcoded values, use reference data that’s fixed in test environments, and validate structure with schemas while asserting exact values only where the business rule demands it.

What did you learn from this project?

Good answers mention a concrete insight, for example that the baseURL needs a trailing slash, that sorting must be checked across pages, or that strict schemas catch problems functional assertions miss.

Advertisement
View the full API testing syllabus

Run Playwright API Tests in CI: GitHub Actions, Allure and JUnit

Run Playwright API Tests in CI: GitHub Actions, Allure and JUnit

Tests that only run on your laptop protect nobody. The real value of an API suite appears when it runs automatically on every pull request, blocks broken builds, and publishes a report the whole team can read. In this final lesson of Module 6 you’ll run the Playwright API framework in GitHub Actions with secrets, tags, sharding and type-checking, produce HTML, JUnit and Allure reports, and see the equivalent Jenkins pipeline.

Quick answerTo run Playwright API tests in CI: install dependencies with npm ci, type-check with npx tsc --noEmit, run npx playwright test with environment variables from the CI secret store, and upload playwright-report/ (HTML), results/junit.xml and allure-results/ as artifacts. Use tags (--grep @smoke) for fast PR checks, sharding (--shard=1/4) to split long suites across machines, retries for rare network blips and forbidOnly to block test.only. Pure API tests don’t need browsers installed.
Advertisement

What a Good API Test Pipeline Does

  1. Triggers on pull requests, merges to main, a nightly schedule and manual runs.
  2. Installs exact dependency versions (npm ci with a lockfile).
  3. Type-checks the test code (Playwright doesn’t).
  4. Runs the right subset: smoke on PRs, full regression nightly or on merge.
  5. Fails the build on test failures (non-zero exit code).
  6. Publishes reports as artifacts and test results in the CI UI.
  7. Keeps secrets secret: credentials from the secret store, never in Git or logs.
Real-life example A fintech team runs 40 smoke API tests on every pull request (under 2 minutes) and 1,200 regression tests nightly across 4 shards (8 minutes). A broken refund calculation was caught on the PR that introduced it, 20 minutes after the commit, instead of by customers after the weekly release.

Prepare the Project for CI

Scripts in package.json keep commands identical locally and in CI:

{
  "scripts": {
    "typecheck": "tsc -p tsconfig.json --noEmit",
    "test": "playwright test",
    "test:smoke": "playwright test --grep @smoke",
    "report": "playwright show-report"
  }
}

Tag your critical tests. The tag syntax (Playwright 1.42+):

import { test, expect } from '@playwright/test';
test('health is up', { tag: ['@smoke', '@health'] }, async ({ request }) => { await expect(await request.get('health')).toBeOK(); });

The config from the framework lesson already sets forbidOnly, retries and workers when process.env.CI is set; GitHub Actions and most CI tools set CI=true automatically.

Advertisement

GitHub Actions Workflow

Save as .github/workflows/api-tests.yml:

name: API tests

on:
  pull_request:
  push:
    branches: [main]
  schedule:
    - cron: '30 1 * * *'          # nightly 01:30 UTC (07:00 IST)
  workflow_dispatch:

jobs:
  api-tests:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2]               # split the suite across 2 machines
    env:
      API_BASE_URL: ${{ vars.API_BASE_URL }}
      ADMIN_USER: ${{ secrets.ADMIN_USER }}
      ADMIN_PASS: ${{ secrets.ADMIN_PASS }}
      VIEWER_USER: ${{ secrets.VIEWER_USER }}
      VIEWER_PASS: ${{ secrets.VIEWER_PASS }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run typecheck
      # Pull requests run the smoke subset; everything else runs the full suite
      - name: Run tests
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            npx playwright test --grep @smoke --shard=${{ matrix.shard }}/2
          else
            npx playwright test --shard=${{ matrix.shard }}/2
          fi
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: report-shard-${{ matrix.shard }}
          path: |
            playwright-report/
            results/junit.xml
            allure-results/
          retention-days: 14

Notes on the important lines:

  • No npx playwright install: pure API tests don’t launch browsers, which saves a minute or more per job. Add it back when the suite includes UI tests.
  • vars vs secrets: non-sensitive values (base URL) as repository variables; passwords and tokens as secrets (masked in logs).
  • if: always() uploads reports even when tests fail, which is exactly when you need them.
  • fail-fast: false lets every shard finish so you see all failures.

Create secrets under Settings → Secrets and variables → Actions. Practice API credentials are public, but use secrets anyway to build the habit.

Advertisement

Sharding: Split Long Suites

--shard=1/4 runs the first quarter of tests, 2/4 the second, and so on. Run each shard on a separate CI machine in parallel. Locally, the framework from this module splits into 28 and 27 tests with --shard=1/2 and --shard=2/2. To combine shard results into one HTML report, use the blob reporter in each shard and npx playwright merge-reports in a final job:

# in each shard
npx playwright test --shard=1/4 --reporter=blob
# in a final job, after downloading all blob-report folders into all-blob-reports/
npx playwright merge-reports --reporter=html ./all-blob-reports

Reports: HTML, JUnit and Allure

ReporterConfigUse
list / line / dot['list']Console output in CI logs
HTML['html', { open: 'never' }]Interactive report with steps, attachments, retries
JUnit['junit', { outputFile: 'results/junit.xml' }]Test results tab in Jenkins, Azure DevOps, GitLab; GitHub via test-reporter actions
Allure['allure-playwright']Rich dashboards, history and trends, categories
blob['blob']Mergeable results from shards

Adding Allure

npm install -D allure-playwright
# run with the Allure reporter (or add ['allure-playwright'] to the reporter list in the config)
npx playwright test --reporter=line,allure-playwright
# generate and open the report (needs the Allure command-line tool, which requires Java)
npx allure generate allure-results --clean -o allure-report
npx allure open allure-report

The allure-playwright reporter writes JSON result files into allure-results/; we verified it produced one result file per test for this framework. Allure’s history and trend charts need the previous report’s history folder copied into allure-results before generating, which hosted setups (Allure TestOps, or a GitHub Pages workflow) do for you.

Which report should a team use? Start with Playwright’s HTML report (zero setup) plus JUnit for the CI’s built-in test tab. Add Allure when stakeholders want dashboards and trends across many runs.
Advertisement

Jenkins Pipeline

Many service companies run Jenkins. The same steps in a declarative Jenkinsfile:

pipeline {
  agent { docker { image 'node:22' } }
  environment {
    API_BASE_URL = 'https://qa.example.com/api/v1/'
    ADMIN        = credentials('qa-admin')        // creates ADMIN_USR and ADMIN_PSW
  }
  triggers { cron('H 1 * * *') }
  stages {
    stage('Install')   { steps { sh 'npm ci' } }
    stage('Typecheck') { steps { sh 'npm run typecheck' } }
    stage('Test') {
      steps {
        sh 'ADMIN_USER=$ADMIN_USR ADMIN_PASS=$ADMIN_PSW npx playwright test'
      }
    }
  }
  post {
    always {
      junit 'results/junit.xml'
      archiveArtifacts artifacts: 'playwright-report/**', allowEmptyArchive: true
    }
  }
}

Jenkins’ junit step turns the JUnit XML into test trends and per-test history. More details are in the Playwright Jenkins lesson and the GitHub Actions tutorial.

Keeping CI Green (and Trustworthy)

  • Flaky tests: track tests that pass on retry (HTML report marks them “flaky”); fix or quarantine with test.fixme() plus a ticket. Never just raise retries.
  • Environment health first: a setup project that logs in also acts as a health gate; if it fails, the run stops with one clear error.
  • Test data hygiene: unique data and cleanup so parallel CI jobs don’t collide.
  • Fast feedback: smoke on PRs in under 5 minutes; heavier suites on merge or nightly.
  • Notifications: send failures to the team’s Slack or Teams channel with a link to the report.
  • Ownership: a failing pipeline is everyone’s priority, not “QA’s problem”.
Advertisement

Module 6 Recap

LessonYou can now…
APIRequestContextConfigure Playwright for APIs and avoid the baseURL trap
CRUD testsWrite independent, parallel-safe CRUD suites with service objects
Assertions & schemasAssert deeply and validate contracts with Zod and Ajv
AuthenticationHandle Basic, API key and Bearer auth with setup projects and role fixtures
Data-drivenGenerate tests from arrays, JSON and CSV
Framework designExplain and build a layered framework
CI (this lesson)Run it all in GitHub Actions or Jenkins with reports

The full framework from this module (55 tests including setup) passes against the live practice API. Next, Module 7 turns these skills into five portfolio projects.

Advertisement

Interview Questions: CI/CD for API Tests

How do you run Playwright API tests in CI/CD?

Install dependencies with npm ci, type-check, run npx playwright test with environment variables from the CI secret store, publish HTML and JUnit (and optionally Allure) reports as artifacts, and let the non-zero exit code fail the pipeline.

Do API tests need browsers installed in CI?

No. APIRequestContext doesn’t use browsers, so npx playwright install can be skipped for pure API suites, which speeds up pipelines.

What is sharding in Playwright?

Splitting the test suite into parts with –shard=x/y and running each part on a separate machine in parallel. Blob reports from shards can be merged into one HTML report with merge-reports.

How do you run only smoke tests on pull requests?

Tag smoke tests (tag: ‘@smoke’ or @smoke in titles) and run npx playwright test –grep @smoke in the PR workflow, with the full suite on merge or nightly.

How do you handle secrets in GitHub Actions?

Store them as repository or environment secrets, expose them to the job as environment variables (${{ secrets.NAME }}), never echo them, and keep non-sensitive config in variables.

Advertisement
What reports do you generate and why?

Playwright’s HTML report for debugging with steps and attachments, JUnit XML for the CI tool’s test results and trends, and Allure for dashboards and history when stakeholders need them.

How do you deal with flaky tests in CI?

Detect them from retry data, investigate causes (shared data, timing, rate limits, environment), fix them, and temporarily quarantine with test.fixme and a ticket. Retries are a safety net, not a fix.

What does forbidOnly do?

It fails the run if any test or describe uses .only, preventing a focused test accidentally committed from silently skipping the rest of the suite in CI.

Why add a type-check step if tests already run?

Playwright transpiles TypeScript without checking types, so type errors can hide until they cause runtime bugs. tsc –noEmit catches them early.

How would you integrate API tests with Jenkins?

Use a Jenkinsfile with stages for install, type-check and test, inject credentials with credentials(), run tests in a Node Docker agent, then publish results with the junit step and archive the HTML report in the post section.

Advertisement
View the full API testing syllabus

API Automation Framework Design with Playwright and TypeScript

API Automation Framework Design with Playwright and TypeScript

Fifty API tests in one file work fine. Fifteen hundred tests, five environments, eight roles and six people committing every day don’t, unless the framework underneath is designed for it. “Explain your framework” is also the question every automation interview spends the most time on. In this lesson we assemble everything from Module 6 into a clean, layered Playwright TypeScript API framework, explain each design decision, and finish with the conventions that keep it maintainable for years.

Quick answerA solid Playwright API automation framework has clear layers: configuration (playwright.config.ts + environment variables), API clients / service objects (one class per resource), models and schemas (TypeScript types + Zod/Ajv), fixtures (pre-built clients, auth per role, cleanup), test data (builders, JSON/CSV files) and tests grouped by feature. Add a setup project for login, tags for smoke/regression, reporters (HTML, JUnit, Allure) and CI. Tests stay short and readable because the layers below hide the plumbing.
Advertisement

The Folder Structure

This is the structure of the framework built in this module. Every file shown in lessons 31 to 35 lives here, and the whole suite passes against the practice API:

sqa-api-tests/
├── playwright.config.ts        # projects, baseURL, reporters, retries
├── tsconfig.json
├── package.json
├── .env.example                # documented variables, no secrets
├── .gitignore                  # .env, .auth/, test-results/, playwright-report/
├── src/
│   ├── config.ts               # credentials + paths from environment variables
│   ├── fixtures.ts             # test.extend: clients, role contexts, cleanup
│   ├── schemas.ts              # Zod schemas + Ajv helper
│   ├── builders.ts             # test data builders
│   └── api/                    # service objects (one per resource)
│       ├── UsersApi.ts
│       ├── ObjectsApi.ts
│       └── AuthApi.ts
├── data/                       # data-driven inputs
│   ├── age-boundaries.json
│   └── object-ttl.csv
└── tests/
    ├── auth.setup.ts           # logs in once per run (setup project)
    ├── request-basics.spec.ts
    ├── crud.spec.ts
    ├── assertions.spec.ts
    ├── auth.spec.ts
    ├── data-driven.spec.ts
    └── builders.spec.ts

The Layers and Why They Exist

LayerResponsibilityWithout it…
ConfigBase URL, headers, timeouts, projects, reportersURLs hardcoded in 300 places; one environment only
Service objectsEndpoints, methods, headers, request shapesAn endpoint change means editing every test
Models / schemasTypes and runtime contractsTypos in field names; structural bugs slip through
FixturesReady clients, auth per role, cleanup, shared setupCopy-pasted login and teardown code everywhere
Test dataBuilders, files, generatorsGiant literal objects repeated in every test
TestsIntent: action + assertions–
Real-life example: the endpoint rename An API team moved /v1/objects to /v2/items. In a framework with service objects, the change was one line in ObjectsApi (the prefix), and 240 tests kept working. In another team’s suite with URLs inside tests, the same change took two days and three broken builds.
Advertisement

Layer 1: Configuration and Environments

The full playwright.config.ts:

import { defineConfig } from '@playwright/test';

try { process.loadEnvFile(); } catch { /* no .env file: rely on real environment variables (CI) */ }

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 1 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: [
    ['list'],
    ['html', { open: 'never' }],
    ['junit', { outputFile: 'results/junit.xml' }],
  ],
  use: {
    // Trailing slash matters: request paths are resolved relative to this URL.
    baseURL: process.env.API_BASE_URL ?? 'https://sqatools.in/wp-json/sqa-api/v1/',
    extraHTTPHeaders: { Accept: 'application/json' },
  },
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    { name: 'api', testIgnore: /.*\.setup\.ts/, dependencies: ['setup'] },
  ],
});
  • process.loadEnvFile() (built into Node.js 20.12+) reads a local .env file, so no extra dependency is needed. In CI the variables come from the pipeline’s secret store.
  • forbidOnly fails CI if someone commits test.only.
  • retries: 1 in CI absorbs rare network blips; a test that needs retries regularly is a bug to fix, not to hide.

Commit a .env.example that documents every variable without real values:

# .env.example - copy to .env and fill in
API_BASE_URL=https://sqatools.in/wp-json/sqa-api/v1/
ADMIN_USER=admin
ADMIN_PASS=
VIEWER_USER=viewer
VIEWER_PASS=

Layer 2: Service Objects

One class per resource; methods return raw APIResponses; a few ...OrThrow helpers for setup. You built ObjectsApi in the CRUD lesson, UsersApi in the data-driven lesson and AuthApi in the auth lesson. Guidelines:

  • No assertions inside service objects (except guards in ...OrThrow helpers).
  • Accept typed inputs (NewObject, UserQuery) so TypeScript catches mistakes.
  • Take the APIRequestContext in the constructor, so the same class works for anonymous, admin and viewer contexts.
  • Keep them thin. Business flows that span services (place an order = cart + payment) go in a separate “flows” or “steps” layer if you need them.
Advertisement

Layer 3: Models and Schemas

src/schemas.ts (from the assertions lesson) holds Zod schemas that double as TypeScript types, and an Ajv helper for official JSON Schemas. Rule of thumb: every response type the suite touches has a schema, and every positive test validates against it.

Layer 4: Fixtures

src/fixtures.ts is the heart of the framework: usersApi, objectsApi, authApi, role contexts adminApi/viewerApi and cleanup. Tests import test and expect from here, never directly from @playwright/test, so new shared capabilities (a custom matcher, a logger) reach every test through one file.

Layer 5: Test Data Builders

A builder returns a valid object with sensible defaults; tests override only the field they’re testing. This keeps tests short and makes the intent obvious. src/builders.ts:

import { NewObject } from './api/ObjectsApi';

let counter = 0;

/** Test data builder: valid defaults, override only what a test cares about. */
export function anObject(overrides: Partial<NewObject> = {}): NewObject {
  counter += 1;
  return {
    name: `auto-${process.pid}-${Date.now()}-${counter}`,
    data: { source: 'playwright' },
    ttl: 60,
    ...overrides,
  };
}

export function aUserPayload(overrides: Record<string, unknown> = {}) {
  return { name: 'Asha Verma', email: 'asha@example.com', age: 30, ...overrides };
}
import { test, expect } from '../src/fixtures';
import { anObject, aUserPayload } from '../src/builders';

test('builder: only the field under test is specified', async ({ objectsApi, cleanup }) => {
  const res = await objectsApi.create(anObject({ ttl: 1800 }));
  expect(res.status()).toBe(201);
  const obj = await res.json();
  cleanup(() => objectsApi.delete(obj.id));
  expect(obj.ttl_seconds).toBe(1800);
});

test('builder: one invalid field, everything else valid', async ({ request }) => {
  const res = await request.post('validate/user', { data: aUserPayload({ age: 101 }) });
  expect(res.status()).toBe(422);
  expect((await res.json()).error.details).toEqual([expect.objectContaining({ field: 'age' })]);
});

The second test proves a subtle point: because every other field is valid, the API reports exactly one error, for age. Negative tests built from a valid baseline isolate the rule under test.

Advertisement

Layer 6: Tests and Conventions

  • Files by feature (orders.spec.ts, refunds.spec.ts), not by test type.
  • Titles that state behaviour: "PATCH merges and null removes a key".
  • Tags for selection: test('...', { tag: '@smoke' }, ...), run with --grep @smoke.
  • Arrange-Act-Assert shape; one behaviour per test.
  • No sleeps; use expect.poll.
  • Independent: own data, cleanup, parallel-safe.

Debuggability: Make Failures Explain Themselves

  • Use await expect(res).toBeOK() and custom messages, so reports show status and body.
  • Attach request/response details on failure with testInfo.attach() (see the bug reporting lesson).
  • Name generated data so it’s traceable (auto-<pid>-<timestamp>) and searchable in server logs.
  • Send a correlation header (e.g. X-Request-Id with the test title) via extraHTTPHeaders if the backend logs it.
Advertisement

Scaling the Framework

Growth problemSolution
Many environmentsOne project per environment, or API_BASE_URL per CI job
Many rolesGeneric apiAs(role) fixture backed by a token map
Slow suiteMore workers, sharding in CI (--shard=1/4), smoke vs full tags
Several servicesFolder per service in src/api/; monorepo with shared fixtures package
API + UISame fixtures; UI tests use service objects for fast setup (Project 5)
Contract driftSchemas generated from OpenAPI; contract tests in CI

Code Quality for Test Code

  • TypeScript strict mode and tsc --noEmit in CI (Playwright runs tests without type-checking, so run tsc separately).
  • ESLint with eslint-plugin-playwright (catches missing awaits, focused tests).
  • Pull-request reviews for tests, like production code.
  • Delete or fix flaky tests quickly; quarantine with test.fixme() and a ticket.
Real gotcha: Playwright doesn’t type-check Playwright transpiles TypeScript but doesn’t report type errors. While building this course, a custom matcher ran perfectly yet failed tsc because res.json() returns any. Only the separate npx tsc --noEmit step caught it. Add that step to CI.
Advertisement

Interview Questions: API Framework Design

Explain your API automation framework.

Describe the layers: Playwright with TypeScript; config with baseURL and environments via variables; service objects per resource; Zod/Ajv schemas; fixtures for clients, role-based auth from a setup project and cleanup; builders and JSON/CSV data; tests by feature with tags; HTML, JUnit and Allure reports; GitHub Actions with secrets and sharding. Mention a concrete benefit, such as an endpoint rename fixed in one line.

What is the service object pattern?

A class per API resource that encapsulates endpoints and request details behind methods, like the Page Object Model for UIs. Tests call methods such as objectsApi.create() instead of building requests.

Why use fixtures instead of beforeEach hooks?

Fixtures are composable, lazy (created only when used), can be test- or worker-scoped, include teardown after use, and are injected by name, making setup reusable and explicit. Hooks run for every test in scope whether needed or not.

How do you manage environments and secrets?

Use environment variables for base URLs and credentials, a local .env file (git-ignored) loaded with process.loadEnvFile or dotenv, an .env.example for documentation, and CI secret stores in pipelines. Projects can target environments too.

What is a test data builder?

A function or class that creates valid test objects with sensible defaults and lets tests override only relevant fields, keeping tests short and making negative tests change one field from a valid baseline.

Advertisement
How do you keep API tests independent?

Each test creates its own data with unique values, uses read-only reference data where shared, registers cleanup, avoids shared mutable state and doesn’t depend on execution order.

How do you organise tests for smoke and regression runs?

Tag tests (for example @smoke, @regression, @slow) and select them with –grep; keep smoke small and critical; run full regression nightly or per merge, possibly sharded.

Does Playwright check TypeScript types?

No. It strips types and runs tests. Run tsc –noEmit separately (locally and in CI) to catch type errors.

How would you add a new API service to the framework?

Create a service object in src/api, add schemas for its responses, expose it through a fixture, add builders for its payloads, then write feature tests and tag critical ones as smoke.

What design principles apply to test frameworks?

Single responsibility per layer, DRY through fixtures and builders, readable tests over clever ones, fast feedback, deterministic and independent tests, and treating test code with the same quality standards as product code.

Advertisement
View the full API testing syllabus

Data-Driven API Testing in Playwright: Arrays, JSON and CSV

Data-Driven API Testing in Playwright: Arrays, JSON and CSV

Boundary values, invalid types, every role, every country code: API testing is full of “same test, different data”. Copy-pasting a test ten times makes a suite impossible to maintain. Data-driven testing writes the logic once and feeds it rows of data from an array, a JSON file or a CSV that even non-coders can edit. In this lesson you’ll build all three styles in Playwright TypeScript, generate unique data safely, and keep every row as its own named, parallel test.

Quick answerPlaywright has no special data-provider syntax because it doesn’t need one: loop over data and call test() inside the loop, giving each row a unique title. Data can come from an inline array, a JSON file (import it with resolveJsonModule) or a CSV file read with fs (or a parser like csv-parse). Each row becomes a separate test in the report, runs in parallel and can be re-run alone with -g. Generate unique values (worker index + timestamp) for anything that must not collide.
Advertisement

Why Data-Driven API Tests?

Real-life example: a loan eligibility API A bank’s eligibility API takes age, income, city tier and credit score. The business gave QA a spreadsheet of 60 rows with expected decisions. Instead of 60 tests, the team wrote one test that reads the sheet (exported to CSV). When rules changed, the business analyst updated the CSV, and nobody touched the code.
  • One logic, many cases: boundaries, types, roles, locales.
  • Readable reports: each row is a named test, e.g. age=101 -> 422 [max+1].
  • Easy to extend: adding a case is adding a row.
  • Shared ownership: business and manual testers can maintain data files.

The Rule: Generate Tests at Collection Time

Playwright collects tests by running the spec file’s top-level code, so a plain for loop that calls test() registers one test per item. Two rules:

  1. Data must be available synchronously at load time (inline arrays, imported JSON, fs.readFileSync). Don’t fetch data with await at the top level to create tests.
  2. Every generated test needs a unique title, so include the data in it.
Advertisement

The Complete Example

Data files first. data/age-boundaries.json:

[
  { "age": 17, "expected": 422, "note": "min-1" },
  { "age": 18, "expected": 200, "note": "min" },
  { "age": 100, "expected": 200, "note": "max" },
  { "age": 101, "expected": 422, "note": "max+1" },
  { "age": "30", "expected": 422, "note": "string" },
  { "age": 30.5, "expected": 422, "note": "decimal" },
  { "age": null, "expected": 422, "note": "null" }
]

data/object-ttl.csv:

ttl,expected
9,422
10,201
1800,201
1801,422

tests/data-driven.spec.ts shows all four techniques:

import { test, expect } from '../src/fixtures';
import fs from 'node:fs';
import path from 'node:path';
import ageCases from '../data/age-boundaries.json';

// 1. Inline array
const pageCases = [
  { per_page: 0, expected: 400 },
  { per_page: 1, expected: 200 },
  { per_page: 50, expected: 200 },
  { per_page: 51, expected: 400 },
];
for (const { per_page, expected } of pageCases) {
  test(`GET users?per_page=${per_page} -> ${expected}`, async ({ usersApi }) => {
    expect((await usersApi.list({ per_page })).status()).toBe(expected);
  });
}

// 2. JSON file (needs "resolveJsonModule": true in tsconfig, or read it with fs)
for (const c of ageCases) {
  test(`validate/user age=${JSON.stringify(c.age)} -> ${c.expected} [${c.note}]`, async ({ request }) => {
    const res = await request.post('validate/user', { data: { name: 'Asha', email: 'asha@example.com', age: c.age } });
    expect(res.status()).toBe(c.expected);
  });
}

// 3. CSV file
function readCsv(file: string): Record<string, string>[] {
  const [header, ...rows] = fs.readFileSync(file, 'utf8').trim().split(/\r?\n/);
  const keys = header.split(',');
  return rows.map(line => Object.fromEntries(line.split(',').map((v, i) => [keys[i], v.trim()])));
}

const objectCases = readCsv(path.join(__dirname, '../data/object-ttl.csv'));
test.describe('POST /objects ttl boundaries (CSV)', () => {
  for (const row of objectCases) {
    test(`ttl=${row.ttl} -> ${row.expected}`, async ({ objectsApi, cleanup }) => {
      const res = await objectsApi.create({ name: `csv ttl ${row.ttl}`, ttl: Number(row.ttl) });
      if (res.status() === 201) cleanup(async () => objectsApi.delete((await res.json()).id));
      expect(res.status()).toBe(Number(row.expected));
    });
  }
});

// 4. Generated unique data per run
test('generated unique names never collide', async ({ objectsApi, cleanup }) => {
  const names = Array.from({ length: 3 }, (_, i) => `run-${Date.now()}-${test.info().workerIndex}-${i}`);
  const ids = new Set<string>();
  for (const name of names) {
    const obj = await objectsApi.createOrThrow({ name, ttl: 30 });
    cleanup(() => objectsApi.delete(obj.id));
    ids.add(obj.id);
  }
  expect(ids.size).toBe(3);
});

This file generates 16 tests. The report reads like a test plan:

✓ GET users?per_page=0 -> 400
✓ GET users?per_page=51 -> 400
✓ validate/user age=17 -> 422 [min-1]
✓ validate/user age="30" -> 422 [string]
✓ POST /objects ttl boundaries (CSV) › ttl=1801 -> 422
...
16 passed

Run one row while debugging: npx playwright test -g "age=101".

The usersApi fixture used above wraps /users with typed query parameters (src/api/UsersApi.ts):

import { APIRequestContext } from '@playwright/test';

export interface UserQuery {
  page?: number;
  per_page?: number;
  role?: 'admin' | 'editor' | 'viewer';
  city?: string;
  active?: boolean;
  q?: string;
  sort?: string;
  order?: 'asc' | 'desc';
}

export class UsersApi {
  constructor(private readonly request: APIRequestContext) {}

  list(query: UserQuery = {}) {
    return this.request.get('users', { params: query as Record<string, string | number | boolean> });
  }

  get(id: number | string) {
    return this.request.get(`users/${id}`);
  }

  orders(id: number) {
    return this.request.get(`users/${id}/orders`);
  }
}
Advertisement

Which Data Source to Use?

SourceBest forWatch out for
Inline arraySmall sets that belong to one test file; types checked by TypeScriptGets long; move to a file past ~15 rows
JSON fileStructured data with types (numbers, null, nested objects)Needs resolveJsonModule or fs; no comments allowed
CSV fileTables shared with business/manual testers (Excel exports)Everything is text (convert numbers); commas inside values need a real parser
Excel (.xlsx)Business-owned rule tablesUse a library like xlsx; export to CSV when possible
Generated dataUnique names, emails, idsMake it reproducible when debugging (log the values)
A real CSV parser The tiny readCsv above is fine for simple files. When values may contain commas, quotes or line breaks, use csv-parse: parse(fs.readFileSync(file), { columns: true, skip_empty_lines: true }) returns the same array of objects.

Typing Your Test Data

Give data rows a type so a typo in a data file is caught early:

type AgeCase = { age: unknown; expected: 200 | 422; note: string };
const cases = ageCases as AgeCase[];

// Or validate the file itself with Zod at load time:
// const cases = z.array(z.object({ age: z.unknown(), expected: z.number(), note: z.string() })).parse(ageCases);
Advertisement

Generating Realistic Test Data

Libraries like @faker-js/faker create realistic names, emails, addresses and phone numbers, including Indian locales:

import { fakerEN_IN as faker } from '@faker-js/faker';

faker.seed(2026);                         // same values every run: reproducible failures
const user = {
  name: faker.person.fullName(),
  email: faker.internet.email().toLowerCase(),
  city: faker.location.city(),
};

Seeding makes “random” data repeatable. Remove or change the seed when you want variety, and always print the generated values in failure messages so a failing row can be reproduced.

Data-Driven Across Environments and Roles

Sometimes the “data” is the environment or role. Playwright projects can run the same tests with different settings:

// playwright.config.ts
projects: [
  { name: 'qa',      use: { baseURL: 'https://qa.example.com/api/v1/' } },
  { name: 'staging', use: { baseURL: 'https://staging.example.com/api/v1/' } },
],
// npx playwright test --project=staging

For roles, combine a role list with a permission matrix (see the authorization lesson) and loop over both dimensions to generate one test per cell.

Advertisement

Common Mistakes

  • Duplicate titles: two rows with the same title make Playwright report a duplicate-title error. Include every distinguishing value.
  • Looping inside one test: the first failing row hides the rest, and the report shows one test. Generate one test per row (or use expect.soft deliberately).
  • Async data loading at the top level to create tests: load data synchronously.
  • Forgetting type conversion from CSV ("10" vs 10), which silently tests the wrong thing.
  • Shared mutable rows: don’t modify the data objects inside tests running in parallel.
  • Huge data sets in functional suites: 5,000 rows belong in a separate nightly job, not every pull request.
Advertisement

Interview Questions: Data-Driven Testing in Playwright

How do you do data-driven testing in Playwright?

Loop over a data source at the top level of the spec file and call test() inside the loop with a unique title per row. Data can be an inline array, an imported JSON file or a CSV/Excel file read synchronously.

Does Playwright have a built-in data provider like TestNG?

No special annotation is needed; standard loops generate parameterised tests. Projects can also parameterise whole runs, for example per environment.

How do you read a CSV file in a Playwright test?

Read it synchronously with fs.readFileSync and split lines and columns for simple files, or use a parser such as csv-parse with columns: true to get an array of objects, then loop over the rows to create tests.

Why should each data row be its own test?

So every row passes or fails independently, appears by name in the report, runs in parallel and can be re-run alone, rather than one failure hiding the rest.

How do you import JSON test data in TypeScript?

Enable resolveJsonModule (and esModuleInterop) in tsconfig and import the file directly, or read and parse it with fs. Add a type or a Zod schema to validate its structure.

Advertisement
How do you generate unique test data?

Combine timestamps, test.info().workerIndex or random UUIDs, or use a library like faker with a seed for realistic and reproducible values.

What is the risk of duplicate test titles?

Playwright requires unique titles within a file and reports duplicates as an error; titles should include the data values that distinguish each case.

How do you run the same tests against multiple environments?

Define a Playwright project per environment with its own baseURL and settings, then select it with –project, or set the base URL through an environment variable.

Where should large data sets run?

In a separate scheduled or nightly job rather than on every commit, keeping the pull-request suite fast while still covering many combinations.

How do you keep data files maintainable?

Use clear column names, include a note or description column, keep expected results next to inputs, validate the file structure, version it in Git, and avoid duplicating the same data in multiple files.

Advertisement
View the full API testing syllabus

API Authentication in Playwright: Basic, API Key, Bearer Tokens

API Authentication in Playwright: Basic, API Key, Bearer Tokens

Almost every real API is protected, and how your framework handles authentication decides whether the suite is fast and clean, or slow and full of copy-pasted login code. Logging in inside every test wastes time, can hit rate limits and spreads credentials everywhere. In this lesson you’ll handle Basic auth, API keys and Bearer tokens the Playwright way: a setup project that logs in once, fixtures that hand each test a ready, authenticated client per role, and focused tests for refresh and logout.

Quick answerIn Playwright: use httpCredentials for Basic auth, extraHTTPHeaders for API keys, and for Bearer tokens log in once in a setup project (a project listed in dependencies), save the tokens, and create custom fixtures that build an APIRequestContext per role with Authorization: Bearer <token> in extraHTTPHeaders. For cookie-based sessions, save and reuse storageState. Keep credentials in environment variables or CI secrets, and still write explicit tests for login failures, expiry, refresh and logout.
Advertisement

Three Ways to Handle Auth (and Which to Pick)

ApproachHowVerdict
Log in inside every testbeforeEach calls /auth/loginSimple but slow; hits login rate limits; noisy
Log in once per workerWorker-scoped fixtureGood for short-lived tokens
Log in once per run (setup project)Setup project saves tokens; fixtures read themFastest; recommended when tokens outlive the run
Real-life example A suite of 600 API tests logged in before each test: 600 extra login calls per run, 4 extra minutes, and the identity provider’s rate limit started returning 429 in CI. Moving login to a setup project cut it to 2 login calls and removed the flaky 429s.

The practice API’s access tokens last 15 minutes, comfortably longer than a typical run. For tokens shorter than your run, use a worker-scoped fixture that refreshes when needed.

Basic Auth and API Keys

These two are just headers, so configure them on a context:

// Basic auth: Playwright builds the Authorization header
const api = await request.newContext({
  baseURL: 'https://sqatools.in/wp-json/sqa-api/v1/',
  httpCredentials: { username: 'sqa_user', password: 'sqa@123', send: 'always' },
});

// API key: a default header on every request
const keyed = await request.newContext({
  baseURL: 'https://sqatools.in/wp-json/sqa-api/v1/',
  extraHTTPHeaders: { 'X-API-Key': process.env.API_KEY ?? 'sqa-key-12345' },
});

send: 'always' sends the credentials on the first request. Without it, Playwright waits for a 401 with WWW-Authenticate before retrying with credentials, which doubles requests and may not work with every API. For a whole project, put either setting in the config’s use block.

Advertisement

Bearer Tokens: The Setup Project Pattern

1. Credentials from the environment

src/config.ts:

import path from 'node:path';

export const TOKENS_FILE = path.join(__dirname, '..', '.auth', 'tokens.json');

// Practice credentials are public. For real systems read them from environment variables / CI secrets.
export const USERS = {
  admin: { username: process.env.ADMIN_USER ?? 'admin', password: process.env.ADMIN_PASS ?? 'admin@123' },
  viewer: { username: process.env.VIEWER_USER ?? 'viewer', password: process.env.VIEWER_PASS ?? 'viewer@123' },
} as const;

Add .auth/ to .gitignore: tokens are secrets too.

2. The setup file

tests/auth.setup.ts runs once before the API tests and logs in each role:

import { test as setup, expect } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { TOKENS_FILE, USERS } from '../src/config';

// Runs once before all API tests (project dependency), logs in each role and saves the tokens.
setup('log in admin and viewer', async ({ request }) => {
  const tokens: Record<string, string> = {};
  for (const [role, creds] of Object.entries(USERS)) {
    const res = await request.post('auth/login', { data: creds });
    await expect(res, `login ${role}`).toBeOK();
    tokens[role] = (await res.json()).access_token;
  }
  fs.mkdirSync(path.dirname(TOKENS_FILE), { recursive: true });
  fs.writeFileSync(TOKENS_FILE, JSON.stringify(tokens));
});

3. Wire it into the config

// playwright.config.ts (projects section)
projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  { name: 'api', testIgnore: /.*\.setup\.ts/, dependencies: ['setup'] },
],

Running npx playwright test now runs setup first; if login fails, the API tests are skipped with a clear reason instead of hundreds of confusing 401 failures.

4. Role fixtures

The complete src/fixtures.ts adds two pre-authenticated contexts, adminApi and viewerApi:

import { test as base, expect, APIRequestContext } from '@playwright/test';
import fs from 'node:fs';
import { ObjectsApi } from './api/ObjectsApi';
import { UsersApi } from './api/UsersApi';
import { AuthApi } from './api/AuthApi';
import { TOKENS_FILE } from './config';

type ApiFixtures = {
  usersApi: UsersApi;
  objectsApi: ObjectsApi;
  authApi: AuthApi;
  adminApi: APIRequestContext;      // request context already carrying the admin token
  viewerApi: APIRequestContext;
  cleanup: (fn: () => Promise<unknown>) => void;
};

const tokens = () => JSON.parse(fs.readFileSync(TOKENS_FILE, 'utf8')) as Record<'admin' | 'viewer', string>;

export const test = base.extend<ApiFixtures>({
  usersApi: async ({ request }, use) => { await use(new UsersApi(request)); },
  objectsApi: async ({ request }, use) => { await use(new ObjectsApi(request)); },
  authApi: async ({ request }, use) => { await use(new AuthApi(request)); },

  adminApi: async ({ playwright, baseURL }, use) => {
    const ctx = await playwright.request.newContext({
      baseURL,
      extraHTTPHeaders: { Accept: 'application/json', Authorization: `Bearer ${tokens().admin}` },
    });
    await use(ctx);
    await ctx.dispose();
  },

  viewerApi: async ({ playwright, baseURL }, use) => {
    const ctx = await playwright.request.newContext({
      baseURL,
      extraHTTPHeaders: { Accept: 'application/json', Authorization: `Bearer ${tokens().viewer}` },
    });
    await use(ctx);
    await ctx.dispose();
  },

  // Register cleanup steps during a test; they run after it, even if it failed.
  cleanup: async ({}, use) => {
    const tasks: Array<() => Promise<unknown>> = [];
    await use(fn => tasks.push(fn));
    for (const task of tasks.reverse()) await task().catch(() => {});
  },
});

export { expect };

Fixtures are lazy: a test that doesn’t ask for adminApi never reads the token file or creates that context.

Advertisement

An AuthApi Service Object

src/api/AuthApi.ts keeps the auth endpoints in one place for tests that exercise auth itself:

import { APIRequestContext, expect } from '@playwright/test';

export interface Tokens {
  access_token: string;
  refresh_token: string;
  token_type: 'Bearer';
  expires_in: number;
}

export class AuthApi {
  constructor(private readonly request: APIRequestContext) {}

  login(username: string, password: string, expires_in?: number) {
    return this.request.post('auth/login', { data: { username, password, expires_in } });
  }

  async loginOrThrow(username: string, password: string): Promise<Tokens> {
    const res = await this.login(username, password);
    await expect(res, `login as ${username}`).toBeOK();
    return res.json();
  }

  me(token: string) {
    return this.request.get('auth/me', { headers: { Authorization: `Bearer ${token}` } });
  }

  refresh(refresh_token: string) {
    return this.request.post('auth/refresh', { data: { refresh_token } });
  }

  logout(token: string) {
    return this.request.post('auth/logout', { headers: { Authorization: `Bearer ${token}` } });
  }
}

toBeOK() is an async matcher, so it must always be awaited, in helpers as well as in tests. A missing await lets the code continue before the check finishes, and a failed login can go unnoticed.

Advertisement

The Auth Tests

tests/auth.spec.ts covers every scheme, roles, refresh and logout:

import { test, expect } from '../src/fixtures';
import { request as pwRequest } from '@playwright/test';
import { ObjectsApi } from '../src/api/ObjectsApi';

const BASE = 'https://sqatools.in/wp-json/sqa-api/v1/';

test.describe('Authentication in Playwright', () => {
  test('Basic auth with httpCredentials', async () => {
    const api = await pwRequest.newContext({
      baseURL: BASE,
      httpCredentials: { username: 'sqa_user', password: 'sqa@123', send: 'always' },
    });
    expect((await api.get('basic/profile')).status()).toBe(200);
    await api.dispose();
  });

  test('API key via extraHTTPHeaders', async () => {
    const api = await pwRequest.newContext({ baseURL: BASE, extraHTTPHeaders: { 'X-API-Key': 'sqa-key-12345' } });
    const res = await api.get('apikey/profile');
    expect((await res.json()).data.plan).toBe('free');
    await api.dispose();
  });

  test('Bearer token from the setup project', async ({ adminApi }) => {
    const me = await (await adminApi.get('auth/me')).json();
    expect(me.data).toMatchObject({ username: 'admin', role: 'admin', can_write: true });
  });

  test('role checks with two pre-authenticated contexts', async ({ adminApi, viewerApi }) => {
    const adminObjects = new ObjectsApi(adminApi, 'token/objects');
    const viewerObjects = new ObjectsApi(viewerApi, 'token/objects');

    expect((await viewerObjects.create({ name: 'viewer try', ttl: 30 })).status()).toBe(403);

    const created = await adminObjects.createOrThrow({ name: 'admin owned', ttl: 60 });
    expect((await viewerObjects.get(created.id)).status()).toBe(200);
    expect((await viewerObjects.delete(created.id)).status()).toBe(403);
    expect((await adminObjects.delete(created.id)).status()).toBe(204);
  });

  test('token refresh rotates; logout revokes', async ({ authApi }) => {
    const tokens = await authApi.loginOrThrow('admin', 'admin@123');

    const refreshed = await authApi.refresh(tokens.refresh_token);
    await expect(refreshed).toBeOK();
    expect((await authApi.refresh(tokens.refresh_token)).status()).toBe(401);   // old refresh token is spent

    const fresh = (await refreshed.json()).access_token;
    expect((await authApi.logout(fresh)).status()).toBe(204);
    expect((await authApi.me(fresh)).status()).toBe(401);
  });

  test('protected endpoints refuse anonymous calls', async ({ request }) => {
    for (const path of ['basic/profile', 'apikey/profile', 'auth/me', 'token/objects']) {
      expect((await request.get(path)).status(), path).toBe(401);
    }
  });
});
Logout tests need their own tokens Never log out the shared admin token from the setup project: every later test using it would get 401. Tests that revoke, expire or tamper with tokens must log in themselves, as the refresh/logout test does.
Advertisement

Short-Lived Tokens: A Worker-Scoped Fixture

If tokens expire in, say, 5 minutes and the run takes 20, log in once per worker and refresh when close to expiry:

import { test as base } from '@playwright/test';

export const test = base.extend<{}, { adminToken: () => Promise<string> }>({
  adminToken: [async ({ playwright }, use) => {
    const api = await playwright.request.newContext({ baseURL: process.env.API_BASE_URL });
    let token = '';
    let expiresAt = 0;
    await use(async () => {
      if (Date.now() > expiresAt - 30_000) {                     // refresh 30 s before expiry
        const res = await api.post('auth/login', { data: { username: 'admin', password: process.env.ADMIN_PASS } });
        const body = await res.json();
        token = body.access_token;
        expiresAt = Date.now() + body.expires_in * 1000;
      }
      return token;
    });
    await api.dispose();
  }, { scope: 'worker' }],
});

Tests call await adminToken() and always get a valid token without logging in more than necessary.

For APIs that use session cookies, log in with a context, then save its cookies:

const ctx = await request.newContext({ baseURL: process.env.APP_URL });
await ctx.post('login', { form: { username: 'qa_user', password: process.env.QA_PASS! } });
await ctx.storageState({ path: '.auth/session.json' });     // cookies + origins
await ctx.dispose();

// later: a context (or a browser page) that starts logged in
const loggedIn = await request.newContext({ baseURL: process.env.APP_URL, storageState: '.auth/session.json' });

The same file works for browser tests via use: { storageState: '.auth/session.json' }, which is how hybrid suites log in through the API and test the UI already signed in.

Advertisement

Security Hygiene for Test Credentials

  • Use dedicated test accounts per environment, never real people’s accounts.
  • Read secrets from process.env; in CI use the secret store (GitHub Actions secrets, Jenkins credentials).
  • Ignore .auth/ and .env in Git; never print tokens in logs or reports.
  • Rotate test credentials; give test users the minimum roles they need.
  • Keep explicit negative auth tests even though most tests use pre-authenticated fixtures.
Advertisement

Interview Questions: Authentication in Playwright API Tests

How do you handle authentication in Playwright API tests?

Use httpCredentials for Basic auth and extraHTTPHeaders for API keys. For Bearer tokens, log in once in a setup project or worker fixture, then provide pre-authenticated APIRequestContexts through custom fixtures with the Authorization header set.

What is a setup project in Playwright?

A project listed in another project’s dependencies that runs first, typically to log in and save tokens or storageState. If it fails, dependent tests are skipped.

How do you test multiple roles?

Create a fixture per role (adminApi, viewerApi) with that role’s token, then use them in the same test to verify allowed and forbidden actions, or drive a permission matrix with data-driven tests.

What is storageState?

A JSON snapshot of cookies and local storage from a context. Saving it after login and loading it into new contexts or browser pages lets tests start already authenticated.

What does send: ‘always’ do in httpCredentials?

It sends the Basic Authorization header with the first request instead of waiting for a 401 challenge, avoiding an extra round trip and issues with APIs that don’t send WWW-Authenticate.

How do you handle tokens that expire during a test run?

Use a worker-scoped fixture that caches the token with its expiry time and logs in or refreshes shortly before it expires.

Why not log in before every test?

It slows the suite, adds load on the auth server, can trigger rate limits and duplicates code. Logging in once and reusing tokens is faster and more reliable.

How do you keep credentials secure in a test framework?

Read them from environment variables or CI secrets, keep token files and .env out of version control, avoid logging secrets, and use least-privilege test accounts.

Why must logout tests use their own tokens?

Logging out revokes the token; if it were the shared token from the setup project, every other test using it would start failing with 401.

What is the difference between test-scoped and worker-scoped fixtures?

Test-scoped fixtures are created for each test; worker-scoped fixtures are created once per worker process and shared by all tests in that worker, which is ideal for expensive setup like logins.

Advertisement
View the full API testing syllabus

Playwright API Assertions and JSON Schema Validation (Zod, Ajv)

Playwright API Assertions and JSON Schema Validation (Zod, Ajv)

A test is only as good as its assertions. “Status is 200” catches outages; it misses wrong totals, missing fields, leaked data and silent type changes. In this lesson you’ll learn every Playwright assertion that matters for API testing, from toBeOK() and partial matching to soft assertions and polling, then lock down whole responses with JSON Schema validation using Zod and Ajv, including a custom toMatchZodSchema matcher. Every example ran green against the live practice API.

Quick answerKey Playwright API assertions: await expect(res).toBeOK() (2xx, prints the body on failure), expect(res.status()).toBe(201), toMatchObject() for partial body checks, toEqual() with expect.any() / expect.stringContaining() for exact shapes with dynamic values, toContainEqual() for arrays, toBeCloseTo() for money, expect.soft() to report several failures at once and expect.poll() for eventual consistency. For structure, validate responses against a schema with Zod (TypeScript types + runtime checks) or Ajv (standard JSON Schema/OpenAPI).
Advertisement

Layers of an API Assertion

LayerExampleCatches
StatustoBe(201), toBeOK()Outages, wrong codes
HeadersContent-Type, Location, ETag, Cache-ControlCaching/security/contract issues
Valuesname === 'Asha Verma', totalsBusiness logic bugs
Structure (schema)Every field present, typed, no extrasRenamed/removed fields, type changes, data leaks
Side effectsGET after POST, 404 after DELETEWrites that weren’t saved
Non-functionalResponse timePerformance regressions

Good suites combine them: a schema check for structure plus targeted value checks for the business rules under test.

The Assertions in Action

tests/assertions.spec.ts (uses the fixtures and schemas from the framework):

import { test, expect } from '../src/fixtures';
import { UserSchema, UserListSchema, ErrorSchema, validateJsonSchema } from '../src/schemas';

test.describe('Assertions and schema validation', () => {
  test('status, headers and timing', async ({ request }) => {
    const start = Date.now();
    const res = await request.get('users/1');
    const elapsed = Date.now() - start;

    await expect(res).toBeOK();                                    // 200-299, prints the body if it fails
    expect(res.headers()).toMatchObject({
      'content-type': expect.stringContaining('application/json'),
      'cache-control': expect.stringContaining('no-store'),
    });
    expect(elapsed).toBeLessThan(3_000);
  });

  test('partial and type-based matching', async ({ usersApi }) => {
    const { data } = await (await usersApi.get(1)).json();
    expect(data).toMatchObject({ id: 1, role: 'admin' });          // only these keys checked
    expect(data).toEqual({                                         // every key checked
      id: 1, name: 'Asha Verma', username: 'asha', email: 'asha@example.com', role: 'admin',
      city: 'Pune', age: 34, active: true, created_at: expect.any(String),
    });
    expect(data.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
  });

  test('arrays: every item, sorting and containment', async ({ usersApi }) => {
    const { data } = await (await usersApi.list({ city: 'pune', per_page: 50 })).json();
    expect(data.length).toBeGreaterThan(0);
    for (const u of data) expect(u.city.toLowerCase()).toBe('pune');

    const sorted = (await (await usersApi.list({ sort: 'age', order: 'desc', per_page: 50 })).json()).data;
    const ages = sorted.map((u: { age: number }) => u.age);
    expect(ages).toEqual([...ages].sort((a: number, b: number) => b - a));
    expect(sorted).toContainEqual(expect.objectContaining({ name: 'Manish Gupta' }));
  });

  test('soft assertions report every failure in one run', async ({ request }) => {
    const { data } = await (await request.get('orders/5001')).json();
    expect.soft(data.status).toBe('delivered');
    expect.soft(data.summary.item_count).toBe(data.items.length);
    expect.soft(data.summary.subtotal).toBe(1947);
    expect.soft(data.summary.tax).toBeCloseTo(1947 * 0.18, 2);
    expect.soft(data.summary.total).toBeCloseTo(2297.46, 2);
  });

  test('Zod schema: single user and list', async ({ usersApi }) => {
    const one = await (await usersApi.get(1)).json();
    expect(() => UserSchema.parse(one.data)).not.toThrow();

    const list = await (await usersApi.list({ per_page: 50 })).json();
    const parsed = UserListSchema.safeParse(list);
    expect(parsed.success, parsed.success ? '' : parsed.error.message).toBe(true);
  });

  test('Ajv: validate against the API-published JSON Schema', async ({ request, usersApi }) => {
    const schema = await (await request.get('schema/user')).json();
    const { data } = await (await usersApi.list({ per_page: 50 })).json();
    for (const user of data) {
      const { ok, errors } = validateJsonSchema(schema, user);
      expect(ok, `user ${user.id}: ${errors}`).toBe(true);
    }
  });

  test('error responses follow the error contract', async ({ request }) => {
    const res = await request.post('validate/user', { data: { name: 'A', email: 'x', age: '30' } });
    expect(res.status()).toBe(422);
    const body = ErrorSchema.parse(await res.json());
    expect(body.error.details?.map(d => d.field).sort()).toEqual(['age', 'email', 'name']);
  });

  test('expect.poll waits for an eventual state (object expiry)', async ({ objectsApi }) => {
    test.setTimeout(40_000);
    const created = await objectsApi.createOrThrow({ name: 'Short lived', ttl: 10 });
    await expect.poll(async () => (await objectsApi.get(created.id)).status(), {
      intervals: [2_000], timeout: 25_000,
    }).toBe(404);
  });
});
Advertisement

Choosing the Right Matcher

NeedMatcherNote
Success with a helpful failure messageawait expect(res).toBeOK()Async; prints status and body when it fails
Exact statusexpect(res.status()).toBe(422)Use for every non-200 expectation
Some fieldstoMatchObject({...})Extra fields allowed; nested objects partially matched
Whole objecttoEqual({...})Combine with expect.any(String) for ids/timestamps
Array contains an itemtoContainEqual(expect.objectContaining({...}))Order-independent
Numbers with decimalstoBeCloseTo(2297.46, 2)Avoids floating point surprises
PatternstoMatch(/regex/)Dates, ids, emails
Several checks, see all failuresexpect.soft(...)Test still fails at the end
Eventually trueexpect.poll(fn, { intervals, timeout })Async jobs, expiry, eventual consistency
Custom failure messageexpect(value, 'user 7 role').toBe('admin')Second argument appears in the report
toMatchObject vs toEqual toMatchObject is forgiving: perfect when the API may add fields later. toEqual is strict: use it when an unexpected new field should fail the test (for example, responses that must never leak data). Schemas give you the strict check without listing every value.
Advertisement

Schema Validation with Zod

Zod defines a schema in TypeScript that is both a runtime validator and a static type. npm install -D zod, then src/schemas.ts:

import { z } from 'zod';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';

// ---- Zod: schema and TypeScript type in one place
export const UserSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1),
  username: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'editor', 'viewer']),
  city: z.string(),
  age: z.number().int().min(18),
  active: z.boolean(),
  created_at: z.string().datetime(),
}).strict();                                   // strict() = no unknown keys (like additionalProperties: false)

export type User = z.infer<typeof UserSchema>;

export const UserListSchema = z.object({
  data: z.array(UserSchema),
  meta: z.object({
    page: z.number(), per_page: z.number(), total: z.number(), total_pages: z.number(),
    sort: z.string(), order: z.enum(['asc', 'desc']),
  }),
  links: z.object({
    self: z.string().url(), first: z.string().url(), last: z.string().url(),
    prev: z.string().url().nullable(), next: z.string().url().nullable(),
  }),
});

export const ErrorSchema = z.object({
  error: z.object({
    status: z.number(),
    code: z.string(),
    message: z.string(),
    details: z.array(z.object({ field: z.string(), message: z.string() })).optional(),
  }),
});

// ---- Ajv: validate against JSON Schema documents (e.g. the API's own /schema/user)
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);

export function validateJsonSchema(schema: object, data: unknown): { ok: boolean; errors: string } {
  const validate = ajv.compile(schema);
  const ok = validate(data) as boolean;
  return { ok, errors: ajv.errorsText(validate.errors) };
}

The User type now gives autocompletion everywhere (user.role is 'admin' | 'editor' | 'viewer'). And the ErrorSchema makes every negative test also verify the error format.

Advertisement

A Custom Matcher: toMatchZodSchema

Turn schema validation into one readable line with a clear failure message, using expect.extend:

import { expect as baseExpect } from '@playwright/test';
import { test } from '../src/fixtures';
import { z } from 'zod';
import { UserSchema } from '../src/schemas';

// A reusable custom matcher: expect(value).toMatchZodSchema(schema)
const expect = baseExpect.extend({
  toMatchZodSchema(received: unknown, schema: z.ZodTypeAny) {
    const result = schema.safeParse(received);
    return {
      pass: result.success,
      name: 'toMatchZodSchema',
      message: () => result.success
        ? 'expected value NOT to match the schema'
        : 'schema mismatch:\n' + result.error.issues.map(i => `  ${i.path.join('.') || '(root)'}: ${i.message}`).join('\n'),
    };
  },
});

test('every user matches the Zod schema (custom matcher)', async ({ usersApi }) => {
  const { data } = (await (await usersApi.list({ per_page: 50 })).json()) as { data: unknown[] };
  for (const user of data) expect(user).toMatchZodSchema(UserSchema);
});

test('the matcher explains what is wrong', async () => {
  const broken = { id: '1', name: 'Asha', extra: true };
  expect(() => expect(broken).toMatchZodSchema(UserSchema)).toThrow(/id: Invalid input|id: Expected number/);
});

Note the as { data: unknown[] } cast: res.json() returns any, and TypeScript doesn’t offer custom matchers on any values, so typing the parsed body (which is good practice anyway) makes toMatchZodSchema available. When a response breaks the contract, the report shows exactly what’s wrong, for example:

schema mismatch:
  id: Invalid input: expected number, received string
  (root): Unrecognized key: "extra"

In a real framework, put the extend call in src/fixtures.ts and export that expect, so every test gets the matcher.

Zod or Ajv?

ZodAjv
Schema written inTypeScript codeJSON Schema (JSON/YAML)
TypeScript typesAutomatic (z.infer)Separate (or generated)
Reuse the API team’s OpenAPI/JSON SchemaNeeds conversionDirectly
Best whenTesters own the expected shapesA published contract exists (OpenAPI, /schema/user)

Many frameworks use both: Ajv against the official spec for contract checks, Zod for typed helpers and test-side models.

Advertisement

Soft Assertions and Polling

expect.soft

Checking an order’s five calculated fields? With normal assertions the first failure stops the test and hides the others. expect.soft records each failure and fails the test at the end, so one run shows every broken calculation. Don’t use soft assertions for prerequisites: if the status is 500, there’s no body to check.

expect.poll

Asynchronous systems (report generation, payment status, search indexing, cache expiry) become correct eventually. Never use fixed sleeps; poll with a timeout. The practice API’s objects expire after their TTL, so the example polls until GET returns 404. Similar retry logic is available as expect(async () => {...}).toPass() when you need a block of assertions to eventually pass.

Assertion Anti-Patterns

  • expect(res.ok()).toBeTruthy() alone: on failure you only learn “expected true”. Prefer toBeOK() or the exact status.
  • Asserting the entire body with hardcoded ids and timestamps: flaky. Use expect.any() and schemas.
  • Checking only the first array item.
  • Comparing money with toBe after calculations.
  • Catching errors inside tests and asserting nothing.
  • waitForTimeout/sleep instead of expect.poll.
Advertisement

Interview Questions: Playwright API Assertions

How do you assert an API response in Playwright?

Check the status with expect(res.status()).toBe() or await expect(res).toBeOK(), headers with res.headers(), and the parsed body from await res.json() using toMatchObject, toEqual, toContainEqual or schema validation.

What is the difference between toMatchObject and toEqual?

toMatchObject checks only the properties you specify and allows extra properties; toEqual requires the whole structure to be equal (ignoring undefined properties). Asymmetric matchers like expect.any(String) help with dynamic values.

What does toBeOK do?

It’s a Playwright API response assertion that passes when the status is 200 to 299 and, when it fails, prints the response status and body, making failures easy to diagnose.

What are soft assertions?

Assertions made with expect.soft that don’t stop the test on failure; all failures are reported at the end and the test is marked failed. Useful for checking many independent fields in one response.

How do you validate a JSON schema in Playwright?

Use a library: Ajv to validate against JSON Schema or OpenAPI definitions, or Zod schemas with parse or safeParse. Wrap it in a helper or a custom matcher created with expect.extend.

Advertisement
Why use Zod in a TypeScript API framework?

A Zod schema is both a runtime validator and a source of TypeScript types via z.infer, so responses are validated and strongly typed from one definition.

How do you test eventually consistent behaviour?

Use expect.poll with a function that fetches the current state, plus intervals and a timeout, or expect(…).toPass() for a block of assertions, instead of fixed sleeps.

How do you create a custom matcher in Playwright?

Call expect.extend with matcher functions that return pass, message and name, then use the returned expect in tests, for example expect(user).toMatchZodSchema(UserSchema).

How do you compare decimal values safely?

Use toBeCloseTo(expected, numDigits), or compare integer minor units such as paise.

How do you add context to a failing assertion?

Pass a custom message as the second argument to expect, for example expect(ok, `user ${id}: ${errors}`).toBe(true), so the report explains which item failed and why.

Advertisement
View the full API testing syllabus

CRUD API Tests in Playwright: Create, Read, Update, Delete

CRUD API Tests in Playwright: Create, Read, Update, Delete

CRUD (Create, Read, Update, Delete) tests are the backbone of every API regression suite, and also where most flaky suites go wrong: tests that depend on each other’s data, run in a fixed order, leave garbage behind and break the moment you turn on parallel workers. In this lesson you’ll write a complete, independent, parallel-safe CRUD suite in Playwright TypeScript using a service-object class and a cleanup fixture, all running against the live practice API.

Quick answerWrite CRUD API tests in Playwright by wrapping each resource in a small service object (e.g. ObjectsApi with create/get/put/patch/delete methods built on APIRequestContext), exposing it through a custom fixture, and making every test independent: create its own data, verify the operation and follow-up state (GET after write), then clean up in a fixture that runs even when the test fails. Use unique ids so tests can run in parallel, and assert status, headers (Location, ETag) and body for each step.
Advertisement

The Anti-Pattern: One Long Chain

test 1: create object  -> saves id in a global variable
test 2: read object    -> uses the global id
test 3: update object  -> uses the global id
test 4: delete object  -> uses the global id
Why it hurts If test 1 fails, tests 2 to 4 fail too, and the report shows four failures for one bug. You can’t run test 3 alone while debugging. With parallel workers the tests run in different processes and the global is empty. Retries re-run one test without its predecessors. Real teams lose days to this pattern.

The fix: each test creates the data it needs. API calls are cheap (milliseconds), so a little duplication buys you independence, parallelism and clear failure reports. If you truly need ordered steps, keep them inside one test (or use test.describe.serial sparingly).

Step 1: A Service Object for the Resource

A service object (the API equivalent of the Page Object Model) hides URLs and request details behind readable methods. src/api/ObjectsApi.ts:

import { APIRequestContext, APIResponse } from '@playwright/test';

export interface NewObject {
  name: string;
  data?: Record<string, unknown> | null;
  ttl?: number;
}

export interface StoredObject extends Required<NewObject> {
  id: string;
  version: number;
  created_by: string;
  created_at: string;
  updated_at: string;
  expires_at: string;
  ttl_seconds: number;
}

/** Service object for /objects. Methods return the raw APIResponse so tests decide what to assert. */
export class ObjectsApi {
  constructor(private readonly request: APIRequestContext, private readonly prefix = 'objects') {}

  create(body: NewObject, headers?: Record<string, string>): Promise<APIResponse> {
    return this.request.post(this.prefix, { data: body, headers });
  }

  get(id: string, headers?: Record<string, string>): Promise<APIResponse> {
    return this.request.get(`${this.prefix}/${id}`, { headers });
  }

  list(): Promise<APIResponse> {
    return this.request.get(this.prefix);
  }

  put(id: string, body: NewObject, headers?: Record<string, string>): Promise<APIResponse> {
    return this.request.put(`${this.prefix}/${id}`, { data: body, headers });
  }

  patch(id: string, body: Partial<NewObject>, headers?: Record<string, string>): Promise<APIResponse> {
    return this.request.patch(`${this.prefix}/${id}`, { data: body, headers });
  }

  delete(id: string, headers?: Record<string, string>): Promise<APIResponse> {
    return this.request.delete(`${this.prefix}/${id}`, { headers });
  }

  /** Convenience for tests that only need a valid object to exist. */
  async createOrThrow(body: NewObject, headers?: Record<string, string>): Promise<StoredObject> {
    const res = await this.create(body, headers);
    if (res.status() !== 201) throw new Error(`create failed: ${res.status()} ${await res.text()}`);
    return res.json();
  }
}

Design choices worth copying:

  • Methods return the raw response, so negative tests can assert 404 or 422. Helper createOrThrow() is for setup steps where failure should stop the test immediately.
  • The prefix parameter lets the same class serve objects, basic/objects and token/objects (used in the auth lesson).
  • Types (NewObject, StoredObject) give autocompletion and catch typos at compile time.
Advertisement

Step 2: Fixtures for the API and for Cleanup

Fixtures give each test a ready-made object and run teardown code automatically. src/fixtures.ts (the full version grows in the next lessons):

import { test as base, expect } from '@playwright/test';
import { ObjectsApi } from './api/ObjectsApi';

type ApiFixtures = {
  objectsApi: ObjectsApi;
  cleanup: (fn: () => Promise<unknown>) => void;
};

export const test = base.extend<ApiFixtures>({
  objectsApi: async ({ request }, use) => { await use(new ObjectsApi(request)); },

  // Register cleanup steps during a test; they run after it, even if it failed.
  cleanup: async ({}, use) => {
    const tasks: Array<() => Promise<unknown>> = [];
    await use(fn => tasks.push(fn));
    for (const task of tasks.reverse()) await task().catch(() => {});
  },
});

export { expect };

Tests import test and expect from this file instead of @playwright/test. The cleanup fixture is a small but powerful idea: register “delete this” right after creating something, and it runs after the test, pass or fail, in reverse order.

Advertisement

Step 3: The CRUD Test Suite

tests/crud.spec.ts, seven independent tests:

import { test, expect } from '../src/fixtures';

test.describe('Objects CRUD', () => {
  test('create returns 201 with Location, ETag and version 1', async ({ objectsApi, cleanup }) => {
    const res = await objectsApi.create({ name: 'Laptop bag', data: { color: 'black', qty: 1 }, ttl: 120 });

    expect(res.status()).toBe(201);
    const obj = await res.json();
    cleanup(() => objectsApi.delete(obj.id));

    expect(res.headers()['location']).toMatch(new RegExp(`/objects/${obj.id}$`));
    expect(res.headers()['etag']).toBe('"v1"');
    expect(obj).toMatchObject({ name: 'Laptop bag', data: { color: 'black', qty: 1 }, version: 1, ttl_seconds: 120 });
    expect(obj.id).toMatch(/^obj_[a-z0-9]+$/);
  });

  test('read after create returns the same data', async ({ objectsApi, cleanup }) => {
    const created = await objectsApi.createOrThrow({ name: 'Read me', data: { qty: 2 }, ttl: 60 });
    cleanup(() => objectsApi.delete(created.id));

    const res = await objectsApi.get(created.id);
    await expect(res).toBeOK();
    expect(await res.json()).toMatchObject({ id: created.id, name: 'Read me', data: { qty: 2 } });
  });

  test('PUT replaces the whole object and bumps the version', async ({ objectsApi, cleanup }) => {
    const created = await objectsApi.createOrThrow({ name: 'Home', data: { city: 'Mumbai', pin: '400001' }, ttl: 60 });
    cleanup(() => objectsApi.delete(created.id));

    const res = await objectsApi.put(created.id, { name: 'Home', data: { pin: '400002' }, ttl: 60 });
    expect(res.status()).toBe(200);
    const updated = await res.json();
    expect(updated.data).toEqual({ pin: '400002' });       // city is gone: full replacement
    expect(updated.version).toBe(2);
  });

  test('PATCH merges and null removes a key', async ({ objectsApi, cleanup }) => {
    const created = await objectsApi.createOrThrow({ name: 'Home', data: { city: 'Mumbai', pin: '400001' }, ttl: 60 });
    cleanup(() => objectsApi.delete(created.id));

    const merged = await (await objectsApi.patch(created.id, { data: { pin: '400002' } })).json();
    expect(merged.data).toEqual({ city: 'Mumbai', pin: '400002' });

    const removed = await (await objectsApi.patch(created.id, { data: { city: null } as any })).json();
    expect(removed.data).toEqual({ pin: '400002' });
    expect(removed.version).toBe(3);
  });

  test('DELETE returns 204, then the object is gone', async ({ objectsApi }) => {
    const created = await objectsApi.createOrThrow({ name: 'Delete me', ttl: 60 });

    expect((await objectsApi.delete(created.id)).status()).toBe(204);
    expect((await objectsApi.get(created.id)).status()).toBe(404);
    expect((await objectsApi.delete(created.id)).status()).toBe(404);
  });

  test('PUT with your own id creates (upsert) and is idempotent', async ({ objectsApi, cleanup }) => {
    const id = `pw-${test.info().workerIndex}-${Date.now()}`;
    cleanup(() => objectsApi.delete(id));
    const body = { name: 'Upsert', data: { n: 1 }, ttl: 60 };

    expect((await objectsApi.put(id, body)).status()).toBe(201);
    const again = await objectsApi.put(id, body);
    expect(again.status()).toBe(200);
    expect((await again.json()).data).toEqual({ n: 1 });
  });

  test('stale If-Match is rejected with 412', async ({ objectsApi, cleanup }) => {
    const created = await objectsApi.createOrThrow({ name: 'Ticket', data: { priority: 'low' }, ttl: 60 });
    cleanup(() => objectsApi.delete(created.id));

    const first = await objectsApi.patch(created.id, { data: { priority: 'high' } }, { 'If-Match': '"v1"' });
    expect(first.status()).toBe(200);
    const stale = await objectsApi.patch(created.id, { data: { status: 'closed' } }, { 'If-Match': '"v1"' });
    expect(stale.status()).toBe(412);
  });
});
npx playwright test tests/crud.spec.ts --workers=4

All seven pass in parallel against the live API, in any order, and each can be run alone with -g "PATCH merges".

Advertisement

What Each Test Teaches

TestTechnique
CreateAssert status, Location (with a regex built from the new id), ETag and the echoed body with toMatchObject
Read after createProve persistence with a follow-up GET
PUTFull replacement: omitted fields disappear; version increments
PATCHMerge semantics and null-removes-key; version tracking across steps
DELETE204, then 404 on read and on second delete (idempotent state)
UpsertClient-chosen unique id per worker (workerIndex + timestamp)
Optimistic lockingHeaders per request; stale version → 412

Test Data Rules for Parallel Runs

  • Unique values: include Date.now(), test.info().workerIndex or crypto.randomUUID() in names and ids you choose.
  • Never depend on list positions in shared collections (other testers and workers add data); search for your id instead.
  • Read-only reference data (users 1 to 12, product 101) can be shared safely because nobody changes it.
  • Short TTLs or cleanup so environments don’t fill with junk. The practice API also expires objects automatically.
  • Don’t share mutable state between tests through module-level variables.
Real-life example A team’s nightly suite failed randomly around 2% of the time. Root cause: two tests used the same hardcoded customer email; when they ran at the same moment on different workers, one got “409 email already exists”. Adding workerIndex and a timestamp to every generated email fixed it permanently.
Advertisement

When You Really Need Ordered Steps

Some flows are inherently sequential (create → approve → ship). Prefer one test with test.step() so the report shows each step, the flow stays atomic and retries replay the whole thing:

import { test, expect } from '../src/fixtures';

test('object lifecycle in one test with steps', async ({ objectsApi }) => {
  const created = await test.step('create', async () => objectsApi.createOrThrow({ name: 'Lifecycle', ttl: 60 }));

  await test.step('update', async () => {
    const res = await objectsApi.patch(created.id, { data: { stage: 'approved' } });
    expect((await res.json()).version).toBe(2);
  });

  await test.step('delete', async () => {
    expect((await objectsApi.delete(created.id)).status()).toBe(204);
  });
});

Checklist for CRUD API Tests

  1. Every test independent; runnable alone and in parallel.
  2. Assert status + key headers + body for each operation.
  3. Verify persistence (GET after write) and absence (404 after delete).
  4. Cover PUT vs PATCH semantics, idempotency and versioning.
  5. Negative CRUD: missing resource, invalid body, wrong method, stale version.
  6. Cleanup registered immediately after creation.
Advertisement

Interview Questions: CRUD API Tests in Playwright

How do you structure CRUD API tests in Playwright?

Wrap each resource in a service object with create, get, put, patch and delete methods, expose it through a custom fixture, and write independent tests that create their own data, verify each operation and follow-up state, and clean up through a fixture.

Why should API tests be independent of each other?

So they can run in parallel, in any order, individually while debugging, and under retries, and so one failure doesn’t cascade into many misleading failures.

What is the service object pattern?

The API equivalent of the Page Object Model: a class per resource or service that encapsulates endpoints, request building and headers behind readable methods, keeping tests short and maintainable.

How do you clean up test data in Playwright?

Use fixtures with teardown code after use(), for example a cleanup fixture where tests register delete callbacks that run after the test even if it fails, or afterEach hooks. Short TTLs or environment resets help too.

How do you make test data unique in parallel runs?

Combine values such as test.info().workerIndex, Date.now() or crypto.randomUUID() into names, emails and client-chosen ids.

Advertisement
What is test.step used for?

To group actions inside a test into named steps that appear in reports and traces, useful for long sequential flows that should stay in one test.

How do you test PUT vs PATCH behaviour?

Create a resource with several fields, send a PUT without one field and assert it’s removed, then send a PATCH with one field and assert the others are kept; also check version or updated_at changes.

How do you verify a DELETE worked?

Assert the delete status (204 or 200), then GET the resource and expect 404, and optionally delete again expecting 404, confirming the state is idempotent.

When would you use test.describe.serial?

Rarely, when tests genuinely must share state in order and can’t be combined. It runs them in order and skips the rest after a failure. One test with steps is usually better.

Why return the raw APIResponse from service methods?

So tests can assert on any outcome, including errors such as 404 or 422, and on headers. Helper methods that throw on failure are useful only for setup steps.

Advertisement
View the full API testing syllabus

Playwright API Testing with APIRequestContext (TypeScript)

Playwright API Testing with APIRequestContext (TypeScript)

Playwright is famous for browser automation, but it’s also one of the best API testing frameworks for TypeScript: fast parallel runs, rich assertions, fixtures, retries, HTML reports and trace files, all without opening a browser. Everything starts with APIRequestContext. In this lesson you’ll set up a Playwright API project from scratch, learn every request option you’ll actually use, and avoid the one baseURL mistake that silently breaks almost every beginner’s first suite.

Quick answerAPIRequestContext is Playwright’s HTTP client. Use the built-in request fixture in tests (request.get(), post(), put(), patch(), delete(), head(), fetch()), or create your own with request.newContext({ baseURL, extraHTTPHeaders, httpCredentials }) and dispose() it afterwards. Each call returns an APIResponse with status(), ok(), headers(), json(), text() and body(). Set baseURL with a trailing slash and write request paths without a leading slash.
Advertisement

Why Playwright for API Testing?

  • One framework for UI and API: create data through the API, verify it in the UI, share fixtures and reports.
  • TypeScript first: autocompletion, type-safe clients and schemas.
  • Fast: tests run in parallel workers by default; API tests take milliseconds.
  • Batteries included: retries, tags, projects, HTML/JUnit reports, traces, CI examples.

Already know the Playwright basics? The Playwright with TypeScript tutorial covers installation and the test runner in depth; the shorter Playwright API testing intro is a good warm-up. This module goes deeper and builds a full framework step by step.

Project Setup (5 Minutes)

mkdir sqa-api-tests && cd sqa-api-tests
npm init -y
npm install -D @playwright/test typescript @types/node
# Browsers are NOT needed for pure API tests, so you can skip "npx playwright install"

Create playwright.config.ts:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  reporter: [['list'], ['html', { open: 'never' }]],
  use: {
    // Trailing slash matters: request paths are resolved relative to this URL.
    baseURL: process.env.API_BASE_URL ?? 'https://sqatools.in/wp-json/sqa-api/v1/',
    extraHTTPHeaders: { Accept: 'application/json' },
  },
});

Every request in every test now starts from the practice API’s base URL and sends Accept: application/json. Switching to another environment is one environment variable: API_BASE_URL=https://qa.example.com/api/ npx playwright test.

Advertisement

The baseURL Trap (Read This Twice)

Playwright resolves request paths the same way a browser resolves links. We ran all three combinations against the live API:

baseURLPath in testActual URL calledResult
.../sqa-api/v1/'users/1'https://sqatools.in/wp-json/sqa-api/v1/users/1200 ✔
.../sqa-api/v1/'/users/1'https://sqatools.in/users/1404 ✘
.../sqa-api/v1 (no slash)'users/1'https://sqatools.in/wp-json/sqa-api/users/1404 ✘
Rule If your API has a path prefix (/api/v1, /wp-json/...), end baseURL with / and write paths without a leading /. A leading slash means “from the domain root” and drops the prefix. If your tests mysteriously get 404s, print res.url() first.

Your First API Tests

tests/request-basics.spec.ts:

import { test, expect, request } from '@playwright/test';

test.describe('APIRequestContext basics', () => {
  test('GET with the built-in request fixture', async ({ request }) => {
    const res = await request.get('users/1');            // resolved against baseURL

    expect(res.status()).toBe(200);
    expect(res.ok()).toBeTruthy();
    expect(res.url()).toBe('https://sqatools.in/wp-json/sqa-api/v1/users/1');
    expect(res.headers()['content-type']).toContain('application/json');

    const body = await res.json();
    expect(body.data.name).toBe('Asha Verma');
  });

  test('query parameters with params', async ({ request }) => {
    const res = await request.get('users', { params: { role: 'admin', sort: 'age', order: 'desc' } });
    await expect(res).toBeOK();
    const { data } = await res.json();
    expect(data.map((u: { id: number }) => u.id)).toEqual([7, 1]);
  });

  test('POST JSON, form and raw bodies', async ({ request }) => {
    const json = await request.post('objects', { data: { name: 'json body', ttl: 30 } });
    expect(json.status()).toBe(201);

    const form = await request.post('echo', { form: { name: 'Asha', city: 'Pune' } });
    expect((await form.json()).headers['content-type']).toContain('application/x-www-form-urlencoded');

    const raw = await request.post('validate/user', {
      headers: { 'Content-Type': 'application/json' },
      data: '{"name": "Asha", ',                          // a string is sent as-is
    });
    expect(raw.status()).toBe(400);
  });

  test('a separate context with its own settings', async () => {
    const api = await request.newContext({
      baseURL: 'https://sqatools.in/wp-json/sqa-api/v1/',
      extraHTTPHeaders: { 'X-API-Key': 'sqa-key-12345' },
      timeout: 10_000,
    });
    const res = await api.get('apikey/profile');
    expect(res.status()).toBe(200);
    await api.dispose();                                  // always free the context
  });

  test('redirects, timeouts and failOnStatusCode', async ({ request }) => {
    const noFollow = await request.get('redirect', { maxRedirects: 0 });
    expect(noFollow.status()).toBe(302);
    expect(noFollow.headers()['location']).toBeTruthy();

    await expect(request.get('delay/5', { timeout: 2_000 })).rejects.toThrow(/Timeout/);
    await expect(request.get('users/999', { failOnStatusCode: true })).rejects.toThrow(/404/);
  });
});
npx playwright test tests/request-basics.spec.ts
npx playwright show-report        # open the HTML report

All five tests pass against the live practice API in a few seconds.

Advertisement

Request Methods and Options

MethodUse
request.get(url, options)GET
request.post / put / patch / deleteWrite methods
request.head(url)Headers only
request.fetch(url, { method: 'OPTIONS' })Any method (OPTIONS, custom)
OptionPurposeExample
paramsQuery string (encoded for you){ page: 2, per_page: 5 }
headersPer-request headers{ 'If-Match': '"v1"' }
dataBody: object → JSON (sets Content-Type), string/Buffer → sent as-is{ name: 'A' }
formURL-encoded form body{ user: 'a', pass: 'b' }
multipartFile uploads{ file: fs.createReadStream('a.pdf') }
timeoutPer-request timeout in ms (0 = none)2_000
maxRedirects0 to assert the redirect itself0
failOnStatusCodeThrow on non-2xx/3xxHandy for setup steps
maxRetriesRetry on network errors (not on HTTP errors)2
ignoreHTTPSErrorsSelf-signed test certificatesNever for production

The APIResponse Object

MethodReturns
status() / statusText()200 / “OK”
ok()true for 200 to 299
headers()Object with lower-case header names
headersArray()Array of {name, value}, needed for repeated headers like Set-Cookie
json() / text() / body()Parsed JSON / string / Buffer
url()Final URL (after redirects)
Playwright doesn’t throw on 4xx/5xx A 404 is a normal response you can assert on, which is exactly what negative tests need. Use await expect(res).toBeOK() when you require success: on failure it prints the status and response body, making failures easy to read.
Advertisement

request Fixture vs newContext vs page.request

Use when
request fixtureMost tests; uses config use settings (baseURL, headers); created and disposed for you
request.newContext() (imported) or playwright.request.newContext() (fixture)Different settings: another base URL, auth headers per role, separate cookies; call dispose()
page.request / context.requestHybrid UI + API tests: shares cookies with the browser context, so an API login logs the page in too
Real-life example: fast UI test setup Instead of clicking through 6 screens to create a test order, a UI test calls page.request.post('/api/orders', ...) in two lines, then opens the order page and checks the UI. The test runs 10x faster and fails only for real UI problems. You’ll build this in Project 5.

Common Beginner Mistakes

  • Leading slash in paths with a prefixed baseURL (404s).
  • Forgetting await before request.get() or res.json().
  • Reading the body twice from different methods is fine, but don’t forget await on each.
  • Not disposing contexts you created (memory leaks in long runs).
  • Using failOnStatusCode: true globally, then wondering why negative tests throw.
  • Hardcoding URLs in tests instead of baseURL.
Advertisement

Interview Questions: Playwright APIRequestContext

How do you do API testing in Playwright?

Use the request fixture (an APIRequestContext) inside tests to send HTTP requests with get, post, put, patch, delete or fetch, then assert on the APIResponse: status, headers and parsed JSON. Configure baseURL and shared headers in playwright.config.ts.

What is APIRequestContext?

Playwright’s API for sending HTTP requests from Node.js. It manages cookies, base URL, default headers, credentials and timeouts. It’s available as the request fixture, via request.newContext(), and as page.request bound to a browser context.

Does Playwright need a browser for API tests?

No. APIRequestContext sends requests directly from Node.js, so pure API tests don’t launch browsers and don’t need browser binaries installed.

How do you send query parameters and JSON bodies?

Pass params for the query string and data for the body. When data is an object, Playwright serialises it as JSON and sets Content-Type: application/json; strings and Buffers are sent as-is. Use form for URL-encoded and multipart for files.

Why does a request with a leading slash return 404 when baseURL has a path?

Paths are resolved like browser URLs: a leading slash means the domain root, so the base path is dropped. End baseURL with a slash and use relative paths without a leading slash.

Advertisement
Does Playwright throw an error for 4xx or 5xx responses?

Not by default; the response is returned so you can assert on it. Set failOnStatusCode: true to throw for non-2xx/3xx, or use expect(response).toBeOK().

What is the difference between request and page.request?

The request fixture is an isolated API context. page.request belongs to the page’s browser context and shares its cookie storage, so API calls and the UI share login state.

How do you test a redirect without following it?

Pass maxRedirects: 0 and assert the 3xx status and the Location header.

How do you set headers for all requests?

Use extraHTTPHeaders in the config’s use section or in request.newContext(); add per-request headers with the headers option.

Why should you dispose an APIRequestContext?

Contexts you create with newContext hold resources such as connections and stored responses; dispose() releases them. Fixture contexts are disposed automatically.

Advertisement
View the full API testing syllabus

API Mocking and Service Virtualization for Testers

API Mocking and Service Virtualization for Testers

Your checkout tests fail every morning because the payment gateway’s sandbox is down. The front-end team is blocked because the new orders API won’t be ready for three weeks. You need to test what happens when the shipping partner returns a 503, but you can’t make their real server fail on demand. Mocking and service virtualization solve all three. In this lesson you’ll learn when to mock and when not to, the main tools, and how to build a controllable fake API inside a Playwright test.

Quick answerAPI mocking replaces a real API with a fake that returns predefined responses, so you can test without the real dependency, test error and edge cases on demand (500, 503, timeouts, malformed data) and start before the API exists. Service virtualization is the same idea at a larger scale: realistic, stateful simulations of whole dependent systems. Common tools: WireMock, Prism (from OpenAPI), Postman mock servers, Mockoon, MSW, and Playwright’s page.route() for mocking in UI tests. Mocks never replace a final check against the real integration.
Advertisement

Mock, Stub, Fake, Virtual Service: What’s the Difference?

TermMeaningExample
StubReturns a fixed responseGET /rates always returns {"USD": 83.2}
MockStub that also verifies how it was called“Was POST /sms called once with this phone number?”
FakeSimplified working implementationIn-memory “payment gateway” that stores payments in a map
Service virtualizationRealistic simulation of a whole dependency, often stateful, with latency and error profilesA virtual core-banking system for an entire QA environment

In everyday conversation people say “mock” for all of these. In interviews, knowing the distinction earns points.

When to Mock (and When Not To)

Real-life example: travel booking A travel site depends on airline inventory, hotel suppliers, payment, SMS and email providers. Their sandboxes are slow, rate-limited and sometimes down; some charge per call. The QA team virtualizes all five for daily regression, and runs a smaller suite against real sandboxes nightly, plus a final integration check before each release.
Mock it when…Don’t mock when…
The dependency is unavailable, unstable, slow or costlyYou’re testing the integration itself (contract, auth, real data formats)
You need error cases on demand (500, 503, timeout, bad JSON)The mock would just repeat your assumptions about the other system
The API doesn’t exist yet (develop and test from the spec)It’s the final pre-release check
You need deterministic data for UI testsThe real service is fast, stable and free to call (like the practice API)
The danger of mocks A mock is a copy of your understanding of the other API. When the real API changes, mocks keep passing while production breaks. Keep mocks honest with contract tests or by generating them from the provider’s OpenAPI spec.
Advertisement

Mocking Tools Overview

ToolTypeBest for
WireMockStandalone server / Docker / Java libraryRich request matching, stateful scenarios, fault injection, recording
Prism (Stoplight)CLI, from OpenAPIInstant mock of a spec, with request validation
Postman mock serversCloudQuick mocks from saved examples, shared with front-end teams
MockoonDesktop app / CLIGUI-friendly local mocks
MSW (Mock Service Worker)JS libraryFront-end and Node test mocking at the network layer
Playwright page.route()Built into PlaywrightMocking or modifying API calls made by the web page under test
ReadyAPI Virtualization, Broadcom/Parasoft SVCommercialEnterprise service virtualization (SOAP, MQ, mainframe)

WireMock in 5 Minutes

docker run -d -p 8080:8080 --name wiremock wiremock/wiremock

Create a stub by POSTing a mapping to WireMock’s admin API (or by placing JSON files in its mappings folder):

curl -X POST http://localhost:8080/__admin/mappings -H "Content-Type: application/json" -d '{
  "request":  { "method": "POST", "urlPath": "/payments", "bodyPatterns": [ { "matchesJsonPath": "$[?(@.amount > 100000)]" } ] },
  "response": { "status": 402, "jsonBody": { "error": "limit_exceeded" }, "fixedDelayMilliseconds": 300 }
}'

# Fault injection: simulate a broken connection
curl -X POST http://localhost:8080/__admin/mappings -H "Content-Type: application/json" -d '{
  "request":  { "method": "GET", "urlPath": "/shipping/rates" },
  "response": { "fault": "CONNECTION_RESET_BY_PEER" }
}'

WireMock also records real traffic into stubs (proxy mode), runs stateful scenarios (first call “pending”, second call “paid”) and lets you verify which requests were received (/__admin/requests).

Advertisement

Prism: A Mock Server from an OpenAPI File

npx @stoplight/prism-cli mock openapi.yaml            # http://127.0.0.1:4010
curl -i http://127.0.0.1:4010/users/1                   # returns the spec's example or a generated value
curl -i "http://127.0.0.1:4010/users/1" -H "Prefer: code=404"   # ask for a specific documented response

Because Prism validates incoming requests against the spec, it also catches front-end bugs (wrong field names, missing required fields) before the real backend exists.

Hands-On: A Controllable Fake API Inside Playwright

For API-level tests of a client or service, you can start a tiny HTTP server in a fixture, tell it what to return per test, and assert how your code reacts. No extra tools needed. Here a fake payment gateway returns success, a 503 with Retry-After, or a slow response:

import { test as base, expect } from '@playwright/test';
import http from 'node:http';
import type { AddressInfo } from 'node:net';

type Reply = { status: number; body?: unknown; headers?: Record<string, string>; delayMs?: number };
type FakeGateway = { url: string; next: (...replies: Reply[]) => void; calls: { method: string; path: string; body: string }[] };

const test = base.extend<{ gateway: FakeGateway }>({
  gateway: async ({}, use) => {
    const queue: Reply[] = [];
    const calls: FakeGateway['calls'] = [];
    const server = http.createServer((req, res) => {
      let body = '';
      req.on('data', (c) => (body += c));
      req.on('end', () => {
        calls.push({ method: req.method!, path: req.url!, body });
        const reply = queue.shift() ?? { status: 500, body: { error: 'no stub' } };
        setTimeout(() => {
          res.writeHead(reply.status, { 'Content-Type': 'application/json', ...reply.headers });
          res.end(reply.body === undefined ? '' : JSON.stringify(reply.body));
        }, reply.delayMs ?? 0);
      });
    });
    await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
    const { port } = server.address() as AddressInfo;
    await use({ url: `http://127.0.0.1:${port}`, next: (...r) => queue.push(...r), calls });
    server.close();
  },
});

// The code under test: a payment client that retries once on 503 using Retry-After
async function chargeWithRetry(request: any, url: string, amount: number) {
  for (let attempt = 1; attempt <= 2; attempt++) {
    const res = await request.post(`${url}/payments`, { data: { amount }, timeout: 2_000 });
    if (res.status() !== 503 || attempt === 2) return res;
    await new Promise((r) => setTimeout(r, Number(res.headers()['retry-after'] ?? 1) * 1000));
  }
}

test('successful charge', async ({ request, gateway }) => {
  gateway.next({ status: 201, body: { id: 'pay_1', status: 'captured' } });
  const res = await chargeWithRetry(request, gateway.url, 2297);
  expect(res.status()).toBe(201);
  expect(JSON.parse(gateway.calls[0].body)).toEqual({ amount: 2297 });
});

test('retries once after 503 with Retry-After, then succeeds', async ({ request, gateway }) => {
  gateway.next(
    { status: 503, body: { error: 'maintenance' }, headers: { 'Retry-After': '1' } },
    { status: 201, body: { id: 'pay_2', status: 'captured' } },
  );
  const res = await chargeWithRetry(request, gateway.url, 500);
  expect(res.status()).toBe(201);
  expect(gateway.calls).toHaveLength(2);
});

test('slow gateway makes the client time out', async ({ request, gateway }) => {
  gateway.next({ status: 201, body: {}, delayMs: 3_000 });
  await expect(chargeWithRetry(request, gateway.url, 100)).rejects.toThrow(/Timeout/);
});

All three tests pass. The same fixture pattern works for SMS, email, shipping or any dependency: queue the replies you need, run your code, then assert both the outcome and the calls the fake received.

Advertisement

Mocking API Calls in UI Tests with page.route()

When testing a web page, Playwright can intercept the browser’s API calls and answer them itself. This is ideal for showing rare UI states such as empty lists, errors and slow loading:

import { test, expect } from '@playwright/test';

test('orders page shows a friendly error when the API fails', async ({ page }) => {
  await page.route('**/api/orders*', (route) =>
    route.fulfill({ status: 503, json: { error: 'maintenance' }, headers: { 'Retry-After': '30' } }));
  await page.goto('https://your-app.test/orders');
  await expect(page.getByText('We are having trouble loading your orders')).toBeVisible();
});

test('modify a real response: force an empty cart', async ({ page }) => {
  await page.route('**/api/cart', async (route) => {
    const response = await route.fetch();          // call the real API
    const json = await response.json();
    json.items = [];                                // change only what we need
    await route.fulfill({ response, json });
  });
  await page.goto('https://your-app.test/cart');
  await expect(page.getByText('Your cart is empty')).toBeVisible();
});

Replace the URLs with your application’s. page.route() affects only that page’s browser traffic; it doesn’t change what the request fixture sends. Playwright can also replay recorded traffic from a HAR file with page.routeFromHAR().

Keeping Mocks Honest

  • Generate mocks from the provider’s OpenAPI spec (Prism) or from recorded real traffic (WireMock record mode, HAR files).
  • Run contract tests so the provider proves the mocked responses are still real.
  • Keep mock data realistic: real field names, formats, sizes, and error bodies copied from the real API.
  • Run a smaller suite against the real integration regularly (nightly or pre-release).
  • Version mocks with the tests in Git and review changes like code.
Advertisement

Test Ideas Only Mocks Make Easy

  • Every documented error code from a dependency (400, 401, 403, 404, 409, 429, 500, 502, 503, 504).
  • Timeouts, connection resets, partial or empty bodies, invalid JSON.
  • Very slow responses (does the UI show a spinner? does the backend time out properly?).
  • Unusual but valid data: 0 items, 10,000 items, very long names, Unicode, null optional fields.
  • State sequences: payment “pending” → “pending” → “captured” across polls.
  • Dates and times: end of month, leap day, daylight saving changes, time zones.
Advertisement

Interview Questions: API Mocking and Service Virtualization

What is API mocking?

Replacing a real API dependency with a fake that returns predefined responses, so tests can run without the real service and can simulate any scenario, including errors and delays.

What is the difference between a stub and a mock?

A stub returns canned responses. A mock also records and verifies how it was called, for example that an SMS API received exactly one request with a specific phone number.

What is service virtualization?

Simulating the behaviour of entire dependent systems, often stateful and with realistic latency and error profiles, so teams can develop and test when the real systems are unavailable, costly or hard to control.

When should you not use mocks?

When testing the actual integration, contracts, authentication and real data formats, and for final pre-release verification. Over-reliance on mocks can hide real integration problems.

Which mocking tools have you used?

Examples: WireMock for stateful stubs and fault injection in Docker, Prism to mock from OpenAPI, Postman mock servers for front-end teams, and Playwright page.route() to mock API responses in UI tests.

Advertisement
How does page.route() work in Playwright?

It intercepts network requests made by the page that match a URL pattern; the handler can fulfill them with a custom response, modify a real response fetched with route.fetch(), continue them with changes, or abort them.

How do you keep mocks in sync with the real API?

Generate them from the provider’s OpenAPI spec or recorded traffic, use contract tests to verify the provider still matches, keep realistic data, and regularly run tests against the real integration.

How would you test a retry mechanism?

Use a mock that returns failures (e.g. 503 with Retry-After) followed by success, then assert the client waits the right time, retries the expected number of times, doesn’t retry non-idempotent calls unsafely and eventually succeeds or fails cleanly.

What is fault injection?

Deliberately introducing failures such as errors, delays, dropped connections or malformed data into dependencies to verify the system handles them gracefully. WireMock supports it directly.

How can testing start before the backend is ready?

Agree an OpenAPI spec, run a mock server from it (Prism or Postman), write tests and front-end code against the mock, then point the same tests at the real API when it’s deployed.

Advertisement
View the full API testing syllabus

API Performance Testing: Load, Stress and Spike Tests with k6

API Performance Testing: Load, Stress and Spike Tests with k6

An API that answers in 120 ms for one tester can take 9 seconds when 20,000 shoppers hit it at midnight on sale day, or simply fall over. Performance testing finds those limits before your users do. In this lesson you’ll learn the metrics that matter (and why averages lie), the main types of performance tests, how to write a load test in k6, how to add quick latency checks to your Playwright suite, and how to read the results like an engineer.

Quick answerAPI performance testing measures response time, throughput and error rate under different loads. Key types: load (expected peak), stress (beyond the limit), spike (sudden jumps), soak (long duration) and scalability tests. Judge results with percentiles (p95, p99), not averages, against agreed SLAs/SLOs such as “p95 < 800 ms and error rate < 1% at 500 requests/second”. Popular tools: k6, JMeter, Gatling, Locust and Artillery.
Advertisement

The Metrics That Matter

MetricMeaningExample target
Response time (latency)Time from sending the request to receiving the full responsep95 < 800 ms
Percentiles (p50, p90, p95, p99)p95 = 95% of requests were faster than thisp99 < 1.5 s
ThroughputRequests per second (RPS) the system handles500 RPS sustained
Error rate% of failed requests (5xx, timeouts, wrong responses)< 1%
Concurrent users / VUsSimultaneous virtual users2,000
Resource usageCPU, memory, DB connections, queue length on serversCPU < 70% at peak
Time to first byte (TTFB)Server think time before the response startsDiagnoses slow backends
Why averages lie: the bank queue Nine customers are served in 1 minute each; one waits 51 minutes because the system hung. The average is 6 minutes, which sounds fine. The p95/p99 is 51 minutes, which is what the angry customer posts on social media. A real API with an average of 200 ms can still have 1 in 100 users waiting 8 seconds. Always report percentiles.

Types of Performance Tests

TypeLoad patternQuestion it answersReal example
Smoke (performance)1 to 5 users, shortDoes the script work, are basics fast?Run on every build
LoadRamp to expected peak, holdDo we meet SLAs at normal peak?Evening dinner rush for a food app
StressKeep increasing past peakWhere’s the breaking point, how does it fail?Find max orders/second
SpikeSudden jump and dropDo we survive and recover?Flash sale opens at 12:00, ticket booking opens
Soak / enduranceModerate load for hoursMemory leaks, connection exhaustion24-hour run before a festive season
ScalabilityLoad while adding serversDoes adding capacity actually help?Auto-scaling validation
Advertisement

Before You Load Test: Ground Rules

Never load test what you don’t own Load testing a system without permission can look like a denial-of-service attack. Use a dedicated performance environment, agree the time window with the team, and tell the operations team. The SQA Tools practice API allows 600 requests per minute per IP and is shared by many learners, so only run the tiny examples in this lesson against it (a few virtual users with pauses), never real load.
  1. Define goals: “p95 < 800 ms, errors < 1% at 300 RPS for 30 minutes”.
  2. Model real traffic: which endpoints, in what mix (e.g. 70% browse, 20% search, 10% checkout), think time between calls.
  3. Production-like environment: similar data volumes, configuration and infrastructure.
  4. Test data: enough unique users, products and tokens so caches don’t make results unrealistically good.
  5. Monitoring: watch server metrics (APM, dashboards) during the test, not just client-side numbers.
Advertisement

Your First k6 Load Test

k6 is a free, open-source load testing tool where scripts are written in JavaScript. Install it from k6.io (Windows, macOS, Linux or Docker), then save this as users-load.js:

import http from 'k6/http';
import { check, sleep } from 'k6';

const BASE = 'https://sqatools.in/wp-json/sqa-api/v1';

export const options = {
  vus: 3,                 // 3 virtual users - deliberately tiny for the shared practice API
  duration: '30s',
  thresholds: {
    http_req_duration: ['p(95)<1500'],   // 95% of requests faster than 1.5 s
    http_req_failed: ['rate<0.01'],      // less than 1% errors
    checks: ['rate>0.99'],
  },
};

export default function () {
  const res = http.get(`${BASE}/users?per_page=5`);
  check(res, {
    'status is 200': (r) => r.status === 200,
    'has 5 users': (r) => r.json('data').length === 5,
  });
  sleep(1);               // think time: a real user pauses between actions
}
k6 run users-load.js

k6 prints a summary including http_req_duration (avg, min, med, max, p90, p95), http_reqs (throughput), http_req_failed and whether each threshold passed. A failed threshold gives a non-zero exit code, so k6 can gate a CI pipeline.

Ramping load with stages (load, stress, spike)

// For YOUR performance environment, not the practice API
export const options = {
  stages: [
    { duration: '2m', target: 100 },    // ramp up to 100 users
    { duration: '10m', target: 100 },   // hold: load test
    { duration: '2m', target: 400 },    // push beyond: stress
    { duration: '30s', target: 1500 },  // sudden spike
    { duration: '2m', target: 0 },      // ramp down: does it recover?
  ],
  thresholds: { http_req_duration: ['p(95)<800'], http_req_failed: ['rate<0.01'] },
};

A realistic user journey

import http from 'k6/http';
import { check, group, sleep } from 'k6';

export default function () {
  let token;
  group('login', () => {
    const res = http.post(`${__ENV.BASE}/auth/login`,
      JSON.stringify({ username: __ENV.USER, password: __ENV.PASS }),
      { headers: { 'Content-Type': 'application/json' } });
    check(res, { 'login 200': (r) => r.status === 200 });
    token = res.json('access_token');
  });
  sleep(2);
  group('browse and order', () => {
    const auth = { headers: { Authorization: `Bearer ${token}` } };
    check(http.get(`${__ENV.BASE}/products?category=books`, auth), { 'browse 200': (r) => r.status === 200 });
    sleep(3);
    // ... add to cart, checkout
  });
}

Run with k6 run -e BASE=https://perf.example.com/api -e USER=perf_user_1 -e PASS=... journey.js; secrets come from the environment, not the script.

Advertisement

Reading Results: A Worked Example

StageUsersRPSp95ErrorsInterpretation
Load100310420 ms0.1%Meets SLA comfortably
Stress4005202.8 s0.4%Throughput flattens while latency climbs: saturation point around 500 RPS
Spike1,5005409.5 s18% (503/504)Overload; check auto-scaling and queueing
Recovery0–––Latency back to normal within 1 minute = good

The pattern to recognise: when throughput stops growing but response time keeps rising, you’ve found a bottleneck (CPU, DB connection pool, a slow query, a lock, a downstream API). Pair the timing with server metrics to locate it.

Rate Limits, Timeouts and Retries

Performance testing also covers how the API protects itself and how clients behave:

  • Rate limits: the practice API’s /rate-limit endpoint allows 5 calls per minute, then returns 429 with Retry-After and X-RateLimit-* headers.
  • Slow dependencies: /delay/{seconds} waits up to 10 seconds, ideal for testing client timeouts.
  • Client behaviour: does the app back off on 429/503, or hammer the server harder (a “retry storm”)?
GEThttps://sqatools.in/wp-json/sqa-api/v1/delay/2
curl -i \
  "https://sqatools.in/wp-json/sqa-api/v1/delay/2"
Advertisement

Quick Latency Checks Inside Your Playwright Suite

You don’t need a load tool to catch obvious regressions. A small sequential sample in your functional suite flags an endpoint that suddenly got slow:

import { test, expect } from '@playwright/test';

const BASE = 'https://sqatools.in/wp-json/sqa-api/v1/';

function percentile(values: number[], p: number) {
  const sorted = [...values].sort((a, b) => a - b);
  return sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)];
}

test('GET users: p95 under 2 s over 15 calls', async ({ request }) => {
  const timings: number[] = [];
  for (let i = 0; i < 15; i++) {
    const start = performance.now();
    const res = await request.get(`${BASE}users?per_page=5`);
    timings.push(performance.now() - start);
    expect(res.status()).toBe(200);
  }
  const p95 = percentile(timings, 95);
  test.info().annotations.push({ type: 'p95 ms', description: p95.toFixed(0) });
  expect(p95).toBeLessThan(2_000);
});

test('client gives up on a slow endpoint', async ({ request }) => {
  const start = Date.now();
  await expect(request.get(`${BASE}delay/5`, { timeout: 2_000 })).rejects.toThrow(/Timeout/);
  expect(Date.now() - start).toBeLessThan(3_500);
});

test('rate limit answers 429 with Retry-After', async ({ request }) => {
  let last;
  for (let i = 0; i < 6; i++) last = await request.get(`${BASE}rate-limit`);
  expect(last!.status()).toBe(429);
  expect(Number(last!.headers()['retry-after'])).toBeGreaterThan(0);
});

These are not load tests (one user, sequential calls); they’re cheap guards against performance regressions that run on every build. The rate-limit test shares a per-IP counter, so don’t run it in parallel with itself.

Tools Compared

ToolScripts inStrengths
k6JavaScriptDeveloper-friendly, thresholds for CI, low resource use, cloud option
JMeterGUI / XMLMature, huge plugin ecosystem, many protocols, widely known in service companies
GatlingScala/Java/Kotlin/JSHigh performance, great HTML reports
LocustPythonEasy for Python teams, distributed
ArtilleryYAML/JSQuick configs, WebSocket and Socket.IO support
Advertisement

Common Performance Testing Mistakes

  • Reporting averages only.
  • Testing from a laptop on office Wi-Fi: the load generator becomes the bottleneck.
  • Using the same user and same product id for every request, so caches hide real costs.
  • No think time: 100 VUs without pauses behave like thousands of real users.
  • Ignoring errors: a fast 500 isn’t a fast success. Check responses in the script.
  • One run and done: compare with a baseline on every release.
Advertisement

Interview Questions: API Performance Testing

What is API performance testing?

Testing how an API behaves under various loads by measuring response time, throughput, error rate and resource usage, to verify it meets performance requirements and to find bottlenecks and limits.

What is the difference between load, stress, spike and soak testing?

Load testing uses expected peak traffic; stress testing pushes beyond capacity to find the breaking point; spike testing applies sudden large jumps; soak testing runs moderate load for a long time to reveal leaks and degradation.

Why use p95 instead of average response time?

Averages hide slow outliers. p95 shows the time within which 95% of requests complete, reflecting the experience of the slowest typical users, which is what SLAs usually target.

What is throughput?

The number of requests (or transactions) the system processes per unit of time, usually requests per second.

What are thresholds in k6?

Pass/fail criteria on metrics, such as http_req_duration p(95)<800 or http_req_failed rate<0.01. If any threshold fails, k6 exits with a non-zero code, failing the CI job.

What is think time and why is it important?

Pauses between actions that simulate real user behaviour. Without think time, a small number of virtual users generate unrealistically high request rates.

How do you identify a bottleneck from results?

When increasing load no longer increases throughput but response times and errors rise, the system is saturated. Correlate with server metrics (CPU, memory, DB connections, slow queries, downstream latency) to find the cause.

Which tools have you used for API performance testing?

Mention one or two with specifics, for example: “k6 for scripted load tests with thresholds in GitHub Actions, and JMeter for a legacy SOAP service, monitored with Grafana dashboards.”

How do you test rate limiting?

Send requests beyond the documented limit within the time window and verify 429 with Retry-After and rate limit headers, that the limit resets after the window, and that limits apply per user, key or IP as designed.

What is an SLA or SLO in performance testing?

A Service Level Agreement or Objective defining measurable targets, such as p95 latency under 800 ms and 99.9% availability, that performance tests verify.

Advertisement
View the full API testing syllabus