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.
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.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
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/objectsandtoken/objects(used in the auth lesson). - Types (
NewObject,StoredObject) give autocompletion and catch typos at compile time.
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.
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".
What Each Test Teaches
| Test | Technique |
|---|---|
| Create | Assert status, Location (with a regex built from the new id), ETag and the echoed body with toMatchObject |
| Read after create | Prove persistence with a follow-up GET |
| PUT | Full replacement: omitted fields disappear; version increments |
| PATCH | Merge semantics and null-removes-key; version tracking across steps |
| DELETE | 204, then 404 on read and on second delete (idempotent state) |
| Upsert | Client-chosen unique id per worker (workerIndex + timestamp) |
| Optimistic locking | Headers per request; stale version → 412 |
Test Data Rules for Parallel Runs
- Unique values: include
Date.now(),test.info().workerIndexorcrypto.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.
workerIndex and a timestamp to every generated email fixed it permanently.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
- Every test independent; runnable alone and in parallel.
- Assert status + key headers + body for each operation.
- Verify persistence (GET after write) and absence (404 after delete).
- Cover PUT vs PATCH semantics, idempotency and versioning.
- Negative CRUD: missing resource, invalid body, wrong method, stale version.
- Cleanup registered immediately after creation.
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.
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.