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