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.
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.The Real-Life Scenario
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 file | Tests | Covers |
|---|---|---|
pagination.spec.ts | 12 | First/middle/last/beyond pages, walking all pages, total_pages maths, headers vs body, 5 invalid parameter cases |
filter-sort-search.spec.ts | 13 | Role, 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.ts | 9 | Schema-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.
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.
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_pagesformula across the edges (1, 5, 7, 12, 50). - Headers vs body:
X-Total-Countandmeta.totalmust agree, since different clients read different ones. - Data-driven negatives also check the error names the right
field.
Step 5: Filtering, Sorting and Search
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.
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
| Bug | Caught 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 responses | Zod .strict() in every test |
| 500 on invalid input | The 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” |
Extend the Project (Assignments)
- Add a
search + filter + sort + paginationcombination test. - Add tests for
Accept: application/xmlon/users/1and an unsupportedAccept(406). - Write a
@smoketag on 5 critical tests and run them with--grep @smoke. - Add a GitHub Actions workflow (see the CI lesson).
- Replace
all()‘s page loop with one that follows thelinks.nextURL 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.
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.
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.