QA How-To
Playwright 1.5 API Testing TypeScript Tutorial (2026)
Follow this Playwright 1.5 API testing TypeScript tutorial to build runnable CRUD, authentication, validation, and cleanup tests with Playwright Test.
22 min read | 2,456 words
TL;DR
Install Playwright Test with TypeScript, configure a base URL, and call the API through the request fixture. Build confidence by testing health, authenticated CRUD, validation, cleanup, and eventual state, with assertions on both the protocol response and persisted business data.
Key Takeaways
- Use the built-in request fixture for tests that share project-level base URL and headers.
- Assert the HTTP status, content type, response schema, and business result instead of checking only success.
- Keep a dependent CRUD lifecycle in one test and delete created data in a finally block.
- Create a separate APIRequestContext when a test needs an isolated identity or cookie jar.
- Send invalid payloads deliberately and confirm rejected operations leave no unwanted state.
- Use expect.poll for eventual consistency while keeping mutations outside the polling callback.
This Playwright 1.5 API testing TypeScript tutorial builds a complete API suite with the modern Playwright 1.5x release line. You will configure the request client, exercise authenticated CRUD operations, validate error responses, isolate identities, and clean up test data with code you can run directly.
The title uses "1.5" because engineers commonly search for the 1.5x family. Do not install the historical 1.5.0 package. The tutorial pins @playwright/test 1.55.0, whose current APIRequestContext, APIResponse, request fixture, and web-first assertion APIs provide the behavior shown here. For deeper client internals after completing the project, read the Playwright APIRequestContext guide.
You will test a small local Tasks API, so the result is reproducible without credentials or a third-party service. The same suite structure transfers to staging services by changing baseURL and authentication variables.
What You Will Build
By the end, you will have a TypeScript project that can:
- start a deterministic local HTTP API before the test run;
- call endpoints through Playwright's built-in
requestfixture; - authenticate with a bearer token configured once;
- create, read, update, list, and delete task records;
- prove validation and authorization failures do not mutate data;
- poll a safe status endpoint without fixed sleeps;
- produce an HTML report with readable test names and response evidence.
The final layout is intentionally small:
playwright-api-tutorial/
package.json
playwright.config.ts
tsconfig.json
server/
task-api.ts
tests/
api/
tasks.spec.ts
isolated-context.spec.ts
This is an API-only project. Installing browser binaries is unnecessary because no test creates a page, context, or browser. That makes the suite quick to install in a container and suitable for a service pipeline.
Prerequisites
Use these exact versions for the tutorial:
| Tool | Version | Purpose |
|---|---|---|
| Node.js | 22.18.0 LTS | Runs the test runner and local API |
| npm | 10.9.3 | Installs locked dependencies and runs scripts |
| TypeScript | 5.9.2 | Type-checks configuration, server, and tests |
| @playwright/test | 1.55.0 | Supplies the runner, request client, assertions, and report |
| tsx | 4.20.5 | Starts the TypeScript API without a manual compile step |
Check the first two tools:
node --version
npm --version
Expected output is v22.18.0 and 10.9.3. A later Node 22 patch generally works, but pin the versions in CI so the local and pipeline environments agree. You need a terminal and an editor. You do not need Java, Python, Docker, or a browser.
Step 1: Create the TypeScript Project
Create an empty directory and initialize npm:
mkdir playwright-api-tutorial
cd playwright-api-tutorial
npm init -y
npm install --save-dev @playwright/test@1.55.0 typescript@5.9.2 tsx@4.20.5 @types/node@22.17.2
mkdir -p server tests/api
Add scripts and module mode to package.json:
{
"name": "playwright-api-tutorial",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"api:start": "tsx server/task-api.ts",
"test": "playwright test",
"test:api": "playwright test tests/api",
"test:report": "playwright show-report",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@playwright/test": "1.55.0",
"@types/node": "22.17.2",
"tsx": "4.20.5",
"typescript": "5.9.2"
}
}
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"types": ["node", "@playwright/test"]
},
"include": ["playwright.config.ts", "server/**/*.ts", "tests/**/*.ts"]
}
strict catches accidental any, missing fields, and unsafe response handling before execution. Module settings match Node's ESM behavior and the package's type field.
Verify Step 1: Run npm run typecheck. It should exit with code 0 and print no TypeScript errors. Run npx playwright --version; it should print Version 1.55.0. Do not run npx playwright install, since this project does not launch browsers.
Step 2: Build a Local Tasks API
Create server/task-api.ts. The server stores records in memory, requires a known bearer token for mutations, validates input, and exposes a delayed processing state so you can practice polling.
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
type Task = {
id: string;
title: string;
completed: boolean;
status: 'queued' | 'ready';
};
const tasks = new Map<string, Task>();
const token = 'tutorial-secret';
function json(res: ServerResponse, status: number, body: unknown) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(body));
}
async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
const text = Buffer.concat(chunks).toString('utf8');
return text ? JSON.parse(text) as Record<string, unknown> : {};
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
if (req.method === 'GET' && url.pathname === '/health') {
return json(res, 200, { status: 'ok', service: 'tasks' });
}
if (req.method === 'GET' && url.pathname === '/tasks') {
return json(res, 200, { items: [...tasks.values()] });
}
const match = url.pathname.match(/^\/tasks\/([^/]+)$/);
if (req.method === 'GET' && match) {
const task = tasks.get(match[1]);
return task ? json(res, 200, task) : json(res, 404, { code: 'TASK_NOT_FOUND' });
}
if (['POST', 'PATCH', 'DELETE'].includes(req.method ?? '') &&
req.headers.authorization !== `Bearer ${token}`) {
return json(res, 401, { code: 'UNAUTHORIZED' });
}
if (req.method === 'POST' && url.pathname === '/tasks') {
const body = await readJson(req);
if (typeof body.title !== 'string' || body.title.trim().length < 3) {
return json(res, 422, { code: 'INVALID_TITLE', field: 'title' });
}
const task: Task = {
id: randomUUID(),
title: body.title.trim(),
completed: false,
status: 'queued',
};
tasks.set(task.id, task);
setTimeout(() => {
const current = tasks.get(task.id);
if (current) current.status = 'ready';
}, 300);
return json(res, 201, task);
}
if (req.method === 'PATCH' && match) {
const task = tasks.get(match[1]);
if (!task) return json(res, 404, { code: 'TASK_NOT_FOUND' });
const body = await readJson(req);
if (typeof body.completed !== 'boolean') {
return json(res, 422, { code: 'INVALID_COMPLETED', field: 'completed' });
}
task.completed = body.completed;
return json(res, 200, task);
}
if (req.method === 'DELETE' && match) {
return tasks.delete(match[1])
? json(res, 204, null)
: json(res, 404, { code: 'TASK_NOT_FOUND' });
}
return json(res, 404, { code: 'ROUTE_NOT_FOUND' });
});
server.listen(3100, '127.0.0.1', () => {
console.log('Task API listening on http://127.0.0.1:3100');
});
The service is deliberately stateful. That lets the tests distinguish a convincing response assertion from proof that the server persisted or rejected a change. Production tests should point to an isolated test environment and use a documented cleanup API, not an in-memory implementation.
Verify Step 2: Run npm run api:start. The terminal should print Task API listening on http://127.0.0.1:3100. In a second terminal, run curl http://127.0.0.1:3100/health. Expect a JSON object with status set to ok and service set to tasks. Stop the server with Ctrl+C because Playwright will manage it next.
Step 3: Configure Playwright API Testing TypeScript
Create playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 15_000,
expect: { timeout: 5_000 },
fullyParallel: true,
workers: 2,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'http://127.0.0.1:3100',
extraHTTPHeaders: {
Accept: 'application/json',
Authorization: `Bearer ${process.env.API_TOKEN ?? 'tutorial-secret'}`,
},
},
webServer: {
command: 'npm run api:start',
url: 'http://127.0.0.1:3100/health',
reuseExistingServer: !process.env.CI,
timeout: 15_000,
},
});
baseURL turns request.get('/health') into a call to the local service. extraHTTPHeaders supplies shared media and authorization headers. A request can override these options when a negative test needs no token. webServer starts the dependency before tests and waits for the health URL instead of guessing readiness with a sleep.
The request fixture is an APIRequestContext created for the test. It follows redirects and stores response cookies. It does not require a browser. A manually created context is appropriate when you need another identity or cookie boundary, which you will add in Step 7.
Verify Step 3: Run npm test -- --list. Playwright should load the configuration and report no syntax error. There are no tests yet, so zero listed tests is correct. Run npm run typecheck again to confirm the config and server compile together.
Step 4: Write the First API Response Assertions
Create tests/api/tasks.spec.ts with a health test and explicit response typing:
import { test, expect } from '@playwright/test';
type Health = { status: string; service: string };
test('health endpoint describes the tasks service', async ({ request }) => {
const response = await request.get('/health');
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('application/json');
const body = await response.json() as Health;
expect(body).toEqual({ status: 'ok', service: 'tasks' });
});
response.ok() is useful for broad success checks, and await expect(response).toBeOK() provides a compact Playwright assertion. Exact status assertions are better when 200, 201, 202, and 204 have different contract meanings. Also inspect content type before parsing JSON because an HTML proxy error can otherwise produce a confusing parse exception.
A TypeScript assertion such as as Health helps the editor but does not validate runtime data. For a public or high-risk contract, add a schema validator such as Zod or Ajv. This tutorial uses direct assertions so every dependency and failure remains visible.
Verify Step 4: Run npm run test:api -- --grep "health endpoint". The list reporter should show one passed test. Temporarily changing service: 'tasks' to another expected value would produce a focused object diff, but restore the correct assertion before continuing.
Step 5: Test an Authenticated CRUD Lifecycle
Append a single test that owns one task from creation through deletion:
type TaskRecord = {
id: string;
title: string;
completed: boolean;
status: 'queued' | 'ready';
};
test('creates, reads, updates, and deletes a task', async ({ request }) => {
const title = `review-contract-${Date.now()}`;
const create = await request.post('/tasks', { data: { title } });
expect(create.status()).toBe(201);
const task = await create.json() as TaskRecord;
expect(task).toMatchObject({ title, completed: false, status: 'queued' });
expect(task.id).toMatch(/^[0-9a-f-]{36}$/);
try {
const read = await request.get(`/tasks/${task.id}`);
expect(read.status()).toBe(200);
expect(await read.json()).toMatchObject({ id: task.id, title });
const update = await request.patch(`/tasks/${task.id}`, {
data: { completed: true },
});
expect(update.status()).toBe(200);
expect(await update.json()).toMatchObject({
id: task.id,
completed: true,
});
} finally {
const remove = await request.delete(`/tasks/${task.id}`);
expect([204, 404]).toContain(remove.status());
}
const missing = await request.get(`/tasks/${task.id}`);
expect(missing.status()).toBe(404);
expect(await missing.json()).toEqual({ code: 'TASK_NOT_FOUND' });
});
Keep these dependent operations together. Four serial tests named create, read, update, and delete would share an identifier and cause misleading cascades when creation fails. The finally block executes even if a read or update assertion throws. Accepting 404 during cleanup makes removal idempotent, while the read after cleanup proves the documented deletion behavior.
Use unique titles because workers run concurrently. Hard-coded data such as test task can collide with another run or stale records. On a shared environment, include a run ID and worker index, then tag the records so a scheduled janitor can remove abandoned data.
Verify Step 5: Run npm run test:api -- --grep "creates, reads". Expect one pass containing the four operations. Run it twice to confirm that data from the first execution does not affect the second.
Step 6: Prove Validation and Authorization Behavior
A negative API test should prove more than an error status. It should validate the stable error contract and confirm the rejected request did not create state. Append these tests:
test('rejects a short title without creating a task', async ({ request }) => {
const before = await request.get('/tasks');
const beforeBody = await before.json() as { items: TaskRecord[] };
const rejected = await request.post('/tasks', { data: { title: 'x' } });
expect(rejected.status()).toBe(422);
expect(await rejected.json()).toEqual({
code: 'INVALID_TITLE',
field: 'title',
});
const after = await request.get('/tasks');
const afterBody = await after.json() as { items: TaskRecord[] };
expect(afterBody.items).toHaveLength(beforeBody.items.length);
});
test('rejects an unauthenticated mutation', async ({ playwright }) => {
const anonymous = await playwright.request.newContext({
baseURL: 'http://127.0.0.1:3100',
extraHTTPHeaders: { Accept: 'application/json' },
});
try {
const response = await anonymous.post('/tasks', {
data: { title: 'must not be created' },
});
expect(response.status()).toBe(401);
expect(await response.json()).toEqual({ code: 'UNAUTHORIZED' });
} finally {
await anonymous.dispose();
}
});
The validation test compares collection size because this tutorial owns the only local server process. In a parallel shared environment, another worker could legitimately add a record between reads. A stronger production design uses a unique rejected marker and queries specifically for that marker, or checks an audit endpoint under a dedicated tenant.
The anonymous client has no authorization header and a separate cookie jar. Disposing manually created contexts releases their resources. Never remove auth from a shared client by mutating global configuration during a test.
Verify Step 6: Run npm run test:api -- --grep "rejects". Expect two passing tests. If the first flakes under heavier parallelism, query by a unique title rather than comparing global collection counts.
Step 7: Poll Eventual State and Isolate Contexts
The API initially returns queued, then changes a task to ready. Test that behavior without waitForTimeout or a fixed delay:
test('eventually marks a created task ready', async ({ request }) => {
const create = await request.post('/tasks', {
data: { title: `async-task-${Date.now()}` },
});
expect(create.status()).toBe(201);
const task = await create.json() as TaskRecord;
try {
await expect.poll(async () => {
const response = await request.get(`/tasks/${task.id}`);
expect(response.status()).toBe(200);
const current = await response.json() as TaskRecord;
return current.status;
}, {
message: `task ${task.id} should become ready`,
timeout: 3_000,
intervals: [100, 200, 400],
}).toBe('ready');
} finally {
await request.delete(`/tasks/${task.id}`);
}
});
Create the task once, outside expect.poll. Playwright reruns the callback until it returns ready or reaches the timeout. Placing the POST inside the callback would generate several tasks and turn an observation into repeated side effects. Poll an idempotent GET and choose a timeout from the service-level expectation.
For real authentication suites, model each role with its own context. Administrator and viewer contexts can have different bearer tokens, client certificates, proxy settings, or storage states. Put their construction in typed fixtures when many tests need them. See Playwright global setup patterns when credentials or seed data must be prepared once per run, but avoid sharing mutable business records across workers.
Verify Step 7: Run npm run test:api -- --grep "eventually". It should finish shortly after 300 ms, not after the full three-second timeout. Change the expected value to missing only as a learning exercise; the failure should include the custom task message and last received value.
Step 8: Run, Report, and Move the Suite to CI
Execute the complete project:
npm run typecheck
npm run test:api
npm run test:report
The suite should report five passed tests. The HTML report opens only when you run the report command. Each test remains independently runnable, and the server starts through webServer. If a test fails, Playwright retains its assertion stack and diff. For richer API diagnostics, attach a redacted response excerpt with testInfo.attach; never attach authorization headers, cookies, personal data, or raw production payloads.
A minimal CI job can install dependencies and run the same checks without browser downloads:
name: api-tests
on:
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.18.0
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm run test:api
env:
API_TOKEN: tutorial-secret
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
For a deployed API, replace the local fallback with a required API_BASE_URL, store its token in the CI secret manager, and refuse to start if either variable is absent. Do not put staging secrets in playwright.config.ts. The GitHub Actions for Playwright guide covers sharding, caching, and artifact decisions once the basic job is stable.
Verify Step 8: Run npm run test:api twice. Both runs should pass without an address-in-use error and without leftover records changing results. Open the report and confirm all five test names appear. In CI, check that the report artifact uploads even after an intentionally failed assertion.
Playwright 1.5 API Testing TypeScript Tutorial: Design Choices
The built-in fixture and a standalone context share the same request API, but their ownership differs:
| Situation | Use | Reason |
|---|---|---|
| Most endpoint tests | request fixture |
Inherits project base URL and shared headers |
| Anonymous negative test | playwright.request.newContext() |
Prevents configured auth from leaking |
| Multiple roles | One context fixture per role | Keeps tokens and cookies isolated |
| UI and API share browser cookies | page.request or browser context request |
Uses the browser context cookie store |
| One-off setup outside a test | Imported request.newContext() |
Provides explicit creation and disposal |
Keep client wrappers thin. A TasksApi class can centralize paths and payload types, but tests should still be able to assert APIResponse.status(), headers, and error bodies. A helper that catches every exception and returns false destroys evidence. A helper that automatically retries every POST can duplicate orders, payments, or jobs.
Separate transport checks from domain checks. Status, headers, redirect behavior, and parsing describe the HTTP contract. Record state, authorization boundaries, and idempotency describe business behavior. High-value tests usually assert both layers selectively rather than snapshotting an entire dynamic response.
Interview Questions and Answers
The interview prompts in the structured section below cover the request fixture, context isolation, cleanup, negative testing, polling, and runtime validation. A strong answer should explain not only which Playwright method to call, but also how the design prevents shared state, duplicate mutations, and misleading passes.
Best Practices
- Pin Playwright and TypeScript in
package-lock.json, then usenpm ciin the pipeline. - Configure a base URL once, but require environment variables for nonlocal targets.
- Give every parallel test its own user, tenant, or uniquely tagged records.
- Assert exact statuses when the distinction between 200, 201, 202, and 204 matters.
- Check content type before parsing an unfamiliar response as JSON.
- Validate critical response schemas at runtime instead of trusting a TypeScript cast.
- Put cleanup in
finallyor fixture teardown and make it safe after partial setup. - Poll only safe reads, with a bounded timeout based on expected service behavior.
- Redact tokens and sensitive fields before attaching request or response evidence.
- Keep UI coverage focused and seed complex prerequisites through an API when that does not bypass the behavior under test.
When the suite expands, review Playwright APIRequestContext examples for multipart uploads, idempotency, protocol checks, and reusable actor fixtures. For mutation retry scenarios specifically, use the API idempotency testing guide to test duplicate delivery and concurrent requests safely.
Troubleshooting
Problem: ECONNREFUSED 127.0.0.1:3100 -> Run npm run api:start manually and call /health. If that works, inspect the webServer.command and URL. In CI, bind the server to 127.0.0.1 and do not point baseURL at localhost if address-family resolution differs.
Problem: EADDRINUSE says port 3100 is already occupied -> Stop the old tutorial server. During local development, reuseExistingServer can reuse a healthy instance, but CI should start a fresh service so stale in-memory data cannot hide a defect.
Problem: every POST returns 401 -> Confirm API_TOKEN is either unset for the local fallback or exactly tutorial-secret. Log whether the header exists, not its secret value. A standalone anonymous context intentionally lacks the configured header.
Problem: response.json() throws an unexpected token error -> Inspect response.status(), response.headers()['content-type'], and a short redacted await response.text(). The response may be an HTML proxy page or an empty 204, neither of which should be parsed as JSON.
Problem: cleanup assertions replace the original failure -> Keep cleanup tolerant only of documented outcomes such as 204 or 404, and attach unexpected cleanup evidence. If preserving the first failure is critical, move resource ownership into a fixture teardown that reports cleanup separately.
Problem: a polling test creates duplicate records -> Move every POST, PATCH, or other mutation before expect.poll. The callback should execute only an idempotent GET or HEAD because Playwright can call it many times.
Where To Go Next
You now have a runnable API test project that covers configuration, authentication, CRUD, negative behavior, context isolation, eventual consistency, cleanup, reporting, and CI. Replace the tutorial server with a test environment only after you define safe seed and cleanup endpoints.
Continue with these verified resources:
- Study the full Playwright APIRequestContext reference guide for cookie behavior and context ownership.
- Adapt production-ready APIRequestContext examples for roles, uploads, and diagnostics.
- Add resilient duplicate-request coverage with API idempotency testing.
- Scale the job using GitHub Actions for Playwright.
- Prepare shared credentials carefully with Playwright global setup examples.
To practice the concepts under interview constraints, try the exercises in QA automation practice. When you are ready to present the project to employers, upload your resume to the QAJobFit resume workspace and describe the concrete contract risks your suite detects.
Conclusion
A useful Playwright API suite is not a collection of successful GET requests. It controls identity and data, asserts protocol and business outcomes, rejects invalid changes, waits for asynchronous work safely, and cleans up even after failure.
Start with the exact local project in this Playwright 1.5 API testing TypeScript tutorial. Once all five tests pass repeatedly, point the configuration at a dedicated test service, replace sample credentials with CI secrets, and add one endpoint family at a time.
Interview Questions and Answers
What is APIRequestContext in Playwright?
APIRequestContext is Playwright's HTTP client context. It stores configuration such as base URL, headers, credentials, and cookies while exposing methods including get, post, patch, delete, head, and fetch. It can come from the request fixture or be created explicitly for an isolated identity.
How do you structure an authenticated CRUD test?
I create a unique resource, capture its server-issued ID, exercise dependent reads and updates, and clean it up in `finally`. I keep that lifecycle in one test so it remains independently runnable. I assert both HTTP details and persisted business state.
Why create separate request contexts for different roles?
Each context has its own headers and cookie storage, which prevents one role's identity from leaking into another role's call. Named actor fixtures also make an authorization matrix readable. Each fixture should own and dispose its context.
What should a negative API test verify besides the status code?
It should check the stable error code, relevant field details, and the absence of the forbidden business effect. For example, after a rejected create, I query by a unique marker and prove that no record exists. This catches services that mutate state but still return an error.
How do you test eventual consistency in Playwright?
I perform the mutation once and use `expect.poll` to repeat a safe GET until a documented state appears. The polling timeout reflects the service expectation, and terminal failure states can fail early. I never put a non-idempotent mutation inside the callback.
What is the difference between TypeScript typing and response schema validation?
TypeScript checks code at compile time and trusts a type assertion applied to parsed JSON. Runtime schema validation inspects the actual payload and rejects missing or wrongly typed fields. I use schema validation at important service boundaries and direct assertions for focused behavior checks.
Frequently Asked Questions
Can Playwright test APIs without launching a browser?
Yes. The request fixture and APIRequestContext send HTTP requests directly, so API-only suites do not need browser binaries. You still receive Playwright Test fixtures, assertions, retries, parallel workers, and reports.
Should I use the request fixture or request.newContext in Playwright?
Use the built-in request fixture for normal tests that inherit project configuration. Create and dispose a separate context when you need isolated authentication, cookies, client certificates, or proxy settings.
How do I set a base URL for Playwright API tests?
Set `use.baseURL` in `playwright.config.ts`, then pass relative paths such as `/tasks` to request methods. For deployed environments, read the URL from an environment variable and fail clearly when it is missing.
How should Playwright API tests clean up created data?
Capture the created identifier and delete the resource in a `finally` block or fixture teardown. Make cleanup idempotent where the service contract allows it, and never depend on a later ordered test to remove state.
Does TypeScript validate a Playwright API response at runtime?
No. A cast describes the expected shape to the compiler but does not inspect incoming JSON. Use explicit assertions or a runtime schema library for contracts where malformed fields must be detected.
How do I test an asynchronous API with Playwright?
Trigger the operation once, then use `expect.poll` around an idempotent status request. Keep mutations outside the polling callback and choose intervals and a timeout from the service's expected completion behavior.