Playwright API Testing Framework — Step-by-step
(with explanations)
Based on the provided project zip. Generated 2026-02-15.
1) What this project is
This repository is a Playwright-based API testing framework (TypeScript) that wraps Playwright’s
APIRequestContext inside a small utility layer so your tests look consistent and you can reuse common
concerns: base URL, headers/auth, logging, schema validation, and custom assertions.
High-level execution flow
Step What happens Where in code
1 Playwright starts and loads config (projects, retries, reporters, etc.) [Link]
2 Test runner builds fixtures (custom test object) and injects helpers into tests
utils/[Link]
3 Tests call RequestHandler to send GET/POST/PUT/DELETE via APIRequestContext
utils/[Link]
4 RequestHandler attaches headers (auth) and logs request/response helpers/[Link] + utils/[Link]
5 Tests validate response status/body and optionally validate JSON schemautils/[Link] + utils/[Link]
2) How to run it locally
Typical workflow:
• Install dependencies: npm install
• Install browsers (if needed): npx playwright install
• Run tests: npx playwright test
• Run a single spec: npx playwright test tests/[Link]
• View report (if configured): npx playwright show-report
Key dependencies and scripts ([Link])
The test runner, TypeScript support, and JSON schema validation libraries are declared here. If you add
new utilities (e.g., dotenv, allure), they go here.
{
"name": "pw-api-testing",
"version": "1.0.0",
"main": "[Link]",
"scripts": {},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@playwright/test": "^1.57.0",
"@types/node": "^22.10.2",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"genson-js": "^0.0.8"
}
}
3) Configuration files
[Link]
This is Playwright Test runner configuration. It defines things like: testDir, timeouts, retries, reporter,
parallelism (workers), and the 'projects' matrix. API tests often disable screenshots/video.
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* [Link]
*/
// import dotenv from 'dotenv';
// import path from 'path';
// [Link]({ path: [Link](__dirname, '.env') });
/**
* See [Link]
*/
export default defineConfig({
testDir: './tests',
fullyParallel: false,
retries: 0,
workers: 1,
reporter: [['html'], ['list']],
use: {},
projects: [
{
name: 'api-testing'
}
],
});
[Link]
This file centralizes API-specific configuration like base URL, endpoints, headers, and credentials. Your
RequestHandler reads from this config so tests don't hardcode URLs everywhere.
const processENV = [Link].TEST_ENV
const env = processENV || 'dev'
[Link]('Test environment is: ' + env)
const config = {
apiUrl: '[Link]
userEmail: 'templateapiuser@[Link]',
userPassword: 'Welcome'
}
if(env === 'qa'){
[Link] = '',
[Link] = ''
}
if(env === 'prod'){
[Link] = '',
[Link] = ''
}
export {config}
4) Fixtures: how helpers get injected into tests
Playwright provides a fixture system. Instead of importing utilities in every test, you extend the base test
object and add your own fixtures (like a RequestHandler, logger, token, etc.). Then tests receive them as
arguments.
utils/[Link] (important ideas)
• extend(test): creates a custom test type with extra fixtures.
• per-test lifecycle: fixtures can be created for each test and disposed automatically.
• type-safety: [Link] defines TypeScript types so autocomplete works.
import { test as base } from '@playwright/test';
import { RequestHandler } from '../utils/request-handler';
import { APILogger } from './logger';
import { setCustomExpectLogger } from './custom-exptect';
import { config } from '../[Link]';
import { createToken } from '../helpers/createToken';
export type TestOptions = {
api: RequestHandler
config: typeof config
}
export type WorkerFixture = {
authToken: string
}
export const test = [Link]<TestOptions, WorkerFixture>({
authToken: [ async ({}, use) => {
const authToken = await createToken([Link], [Link])
await use(authToken)
}, {scope: 'worker'}],
api: async({request, authToken}, use) => {
const logger = new APILogger()
setCustomExpectLogger(logger)
const requestHandler = new RequestHandler(request, [Link], logger, authToken)
await use(requestHandler)
},
config: async({}, use) => {
await use(config)
}
})
5) RequestHandler: the core wrapper around Playwright
APIRequestContext
Playwright gives you an APIRequestContext (think: a lightweight HTTP client). This framework wraps it so
you can: (a) send requests with consistent headers, (b) centralize common options (base URL, timeouts),
(c) log every request/response, (d) reduce duplication across tests.
What RequestHandler typically exposes
• get(url, options), post(url, data, options), put, patch, delete
• A single place to attach Authorization header (token) or other defaults
• Optional retry or rate-limit handling (not always present, but this is where you'd add it)
utils/[Link] (read with these questions in mind)
• Where does it get baseURL from?
• Where does it add headers/auth?
• How does it log requests/responses?
• What does it return: raw Response vs parsed JSON?
import { APIRequestContext } from "@playwright/test"
import { APILogger } from "./logger";
import { test } from "@playwright/test"
export class RequestHandler {
private request: APIRequestContext
private logger: APILogger
private baseUrl: string | undefined
private defaultBaseUrl: string
private apiPath: string = ''
private queryParams: object = {}
private apiHeaders: Record<string, string> = {}
private apiBody: object = {}
private defaultAuthToken: string
private clearAuthFlag: boolean | undefined
constructor(request: APIRequestContext, apiBaseUrl: string, logger: APILogger, authToken: string
[Link] = request
[Link] = apiBaseUrl
[Link] = logger
[Link] = authToken
}
url(url: string) {
[Link] = url
return this
}
path(path: string) {
[Link] = path
return this
}
params(params: object) {
[Link] = params
return this
}
headers(headers: Record<string, string>) {
[Link] = headers
return this
}
body(body: object) {
[Link] = body
return this
}
clearAuth() {
[Link] = true
return this
}
async getRequest(statusCode: number) {
let responseJSON: any
const url = [Link]()
await [Link](`GET request to: ${url}`, async () => {
[Link]('GET', url, [Link]())
const response = await [Link](url, {
headers: [Link]()
})
[Link]()
const actualStatus = [Link]()
responseJSON = await [Link]()
[Link](actualStatus, responseJSON)
[Link](actualStatus, statusCode, [Link])
})
return responseJSON
}
async postRequest(statusCode: number) {
let responseJSON: any
const url = [Link]()
await [Link](`POST request to: ${url}`, async () => {
[Link]('POST', url, [Link](), [Link])
const response = await [Link](url, {
headers: [Link](),
data: [Link]
})
[Link]()
const actualStatus = [Link]()
try {
responseJSON = await [Link]()
} catch (error) {
responseJSON = {}
}
[Link](actualStatus, responseJSON)
[Link](actualStatus, statusCode, [Link])
})
return responseJSON
}
async putRequest(statusCode: number) {
let responseJSON: any
const url = [Link]()
await [Link](`PUT request to: ${url}`, async () => {
[Link]('PUT', url, [Link](), [Link])
const response = await [Link](url, {
headers: [Link](),
data: [Link]
})
[Link]()
const actualStatus = [Link]()
try {
responseJSON = await [Link]()
} catch (error) {
responseJSON = {}
}
[Link](actualStatus, responseJSON)
[Link](actualStatus, statusCode, [Link])
})
return responseJSON
}
async deleteRequest(statusCode: number) {
const url = [Link]()
await [Link](`DELETE request to: ${url}`, async () => {
[Link]('DELETE', url, [Link]())
const response = await [Link](url, {
headers: [Link]()
})
[Link]()
const actualStatus = [Link]()
[Link](actualStatus)
[Link](actualStatus, statusCode, [Link])
})
}
private getUrl() {
const url = new URL(`${[Link] ?? [Link]}${[Link]}`)
for (const [key, value] of [Link]([Link])) {
[Link](key, value)
}
return [Link]()
}
private statusCodeValidator(actualStatus: number, expectStatus: number, callingMethod: Function)
if (actualStatus !== expectStatus) {
const logs = [Link]()
const error = new Error(`Expected status ${expectStatus} but got ${actualStatus}\n\nRece
\n${logs}`)
[Link](error, callingMethod)
throw error
}
}
private getHeades() {
if (![Link]) {
[Link]['Authorization'] = [Link]['Authorization'] || [Link]
}
return [Link]
}
private cleanupFields() {
[Link] = {}
[Link] = {}
[Link] = undefined
[Link] = ''
[Link] = {}
[Link] = false
}
}
6) Authentication helper
Many APIs require a token. Instead of repeating 'login and extract token' in every test, you do it once in a
helper and reuse it. In Playwright API tests, you often call the auth endpoint using APIRequestContext, then
store the token and pass it as Authorization: Bearer ....
helpers/[Link]
import { RequestHandler } from "../utils/request-handler";
import { config } from "../[Link]";
import { APILogger } from "../utils/logger";
import { request } from "@playwright/test";
export async function createToken(email: string, password: string) {
const context = await [Link]()
const logger = new APILogger()
const api = new RequestHandler(context, [Link], logger)
try {
const tokenResponse = await api
.path('/users/login')
.body({ "user": { "email": email, "password": password } })
.postRequest(200)
return 'Token ' + [Link]
} catch(error) {
[Link](error, createToken)
throw error
} finally {
await [Link]()
}
Where to hook this: usually the fixture creates the token once per worker or once per test (depends on
whether tokens expire quickly).
7) JSON Schema validation
Schema validation catches regressions when the response shape changes (missing fields, wrong types,
etc.). This framework keeps schema files under response-schemas/ and validates responses via a utility.
utils/[Link]
import fs from 'fs/promises'
import path from 'path'
import Ajv from "ajv"
import { createSchema } from 'genson-js';
import addFormats from "ajv-formats"
const SCHEMA_BASE_PATH = './response-schemas'
const ajv = new Ajv({ allErrors: true })
addFormats(ajv)
export async function validateSchema(dirName: string, fileName: string, responseBody: object, create
= false) {
const schemaPath = [Link](SCHEMA_BASE_PATH, dirName, `${fileName}_schema.json`)
if(createSchemaFlag) await generateNewSchema(responseBody, schemaPath)
const schema = await loadSchema(schemaPath)
const validate = [Link](schema)
const valid = validate(responseBody)
if (!valid) {
throw new Error(
`Schema validation ${fileName}_schema.json failed:\n`+
`${[Link]([Link], null, 4)}\n\n`+
`Actual response body: \n`+
`${[Link](responseBody, null, 4)}`
)
}
}
async function loadSchema(schemaPath: string) {
try {
const schemaContent = await [Link](schemaPath, 'utf-8')
return [Link](schemaContent)
} catch (error) {
throw new Error(`Failed to read the schema file: ${[Link]}`)
}
}
async function generateNewSchema(responseBody: object, schemaPath: string) {
try {
const generatedSchema = createSchema(responseBody)
await [Link]([Link](schemaPath), {recursive: true})
await [Link](schemaPath, [Link](generatedSchema, null, 4))
} catch (error) {
throw new Error(`Failed to create schema file: ${[Link]}`)
}
}
Example schema file (GET articles)
{
"type": "object",
"properties": {
"articles": {
"type": "array",
"items": {
"type": "object",
"properties": {
"slug": {
"type": "string"
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"body": {
"type": "string"
},
"tagList": {
"type": "array",
"items": {
"type": "string"
}
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"updatedAt": {
"type": "string",
"format": "date-time"
},
"favorited": {
"type": "boolean"
},
"favoritesCount": {
"type": "integer"
},
"author": {
"type": "object",
"properties": {
"username": {
"type": "string"
},
"bio": {
"type": "null"
},
"image": {
"type": "string"
},
"following": {
"type": "boolean"
}
},
"required": [
"username",
"bio",
"image",
"following"
]
}
},
"required": [
"slug",
"title",
"description",
"body",
"tagList",
"createdAt",
"updatedAt",
"favorited",
"favoritesCount",
"author"
]
}
},
"articlesCount": {
"type": "integer"
}
},
"required": [
"articles",
"articlesCount"
]
}
8) Logging and custom assertions
Logging makes failures debuggable without reruns. Custom assertions are a way to standardize what you
validate (status codes, response times, schema checks, etc.) so tests stay short.
utils/[Link]
export class APILogger {
private recentLogs: any[] = []
logRequest(method: string, url: string, headers: Record<string, string>, body?: any){
const logEntry = {method, url, headers, body}
[Link]({type: 'Request Details', data: logEntry})
}
logResponse(statusCode: number, body?: any){
const logEntry = {statusCode, body}
[Link]({type: 'Response Details', data: logEntry})
}
getRecentLogs(){
const logs = [Link](log => {
return `===${[Link]}===\n${[Link]([Link], null, 4)}`
}).join('\n\n')
return logs
}
utils/[Link]
This file extends assertions or wraps common checks. For example: expectStatus200(response) or
expectSchema(body, schema). Even if you can call Playwright expect directly, wrappers keep your
assertions consistent across the suite.
import { expect as baseExpect } from '@playwright/test';
import { APILogger } from './logger';
import { validateSchema } from './schema-validator';
let apiLogger: APILogger
export const setCustomExpectLogger = (logger: APILogger) => {
apiLogger = logger
}
declare global {
namespace PlaywrightTest {
interface Matchers<R, T>{
shouldEqual(expected: T): R
shouldBeLessThanOrEqual(expected: T): R
shouldMatchSchema(dirName: string, fileName: string, createSchemaFlag?: boolean): Promis
}
}
}
export const expect = [Link]({
async shouldMatchSchema(received: any, dirName: string, fileName: string, createSchemaFlag: bool
let pass: boolean;
let message: string = ''
try {
await validateSchema(dirName, fileName, received, createSchemaFlag)
pass = true;
message = 'Schema validation passed'
} catch (e: any) {
pass = false;
const logs = [Link]()
message = `${[Link]}\n\nRecent API Activity: \n${logs}`
}
return {
message: () => message,
pass
};
},
shouldEqual(received: any, expected: any) {
let pass: boolean;
let logs: string = ''
try {
baseExpect(received).toEqual(expected);
pass = true;
if ([Link]) {
logs = [Link]()
}
} catch (e: any) {
pass = false;
logs = [Link]()
}
const hint = [Link] ? 'not' : ''
const message = [Link]('shouldEqual', undefined, undefined, { isNot: [Link]
'\n\n' +
`Expected: ${hint} ${[Link](expected)}\n` +
`Received: ${[Link](received)}\n\n` +
`Recent API Activity: \n${logs}`
return {
message: () => message,
pass
};
},
shouldBeLessThanOrEqual(received: any, expected: any) {
let pass: boolean;
let logs: string = ''
try {
baseExpect(received).toBeLessThanOrEqual(expected);
pass = true;
if ([Link]) {
logs = [Link]()
}
} catch (e: any) {
pass = false;
logs = [Link]()
}
const hint = [Link] ? 'not' : ''
const message = [Link]('shouldBeLessThanOrEqual', undefined, undefined, { is
'\n\n' +
`Expected: ${hint} ${[Link](expected)}\n` +
`Received: ${[Link](received)}\n\n` +
`Recent API Activity: \n${logs}`
return {
message: () => message,
pass
};
}
})
9) Walkthrough of a real test
Reading tests becomes easy if you know what fixtures provide. The test below typically does:
• Arrange: build request URL and body
• Act: call RequestHandler (GET/POST)
• Assert: status code, key fields, schema
tests/[Link]
import { test } from '../utils/fixtures';
import { expect } from '../utils/custom-exptect';
test('Create and Delete Article', async ({ api }) => {
const createArticleResponse = await api
.path('/articles')
.body({ "article": { "title": "Hello World", "description": "Hello World", "body": "HELLO",
.postRequest(201)
await expect(createArticleResponse).shouldMatchSchema('articles', 'POST_articles')
expect([Link]).shouldEqual('Hello World')
const slugId = [Link]
const articlesResponse = await api
.path('/articles')
.params({ limit: 10, offset: 0 })
.getRequest(200)
await expect(articlesResponse).shouldMatchSchema('articles', 'GET_articles')
expect([Link][0].title).shouldEqual('Hello World')
await api
.path(`/articles/${slugId}`)
.deleteRequest(204)
const articlesResponseTwo = await api
.path('/articles')
.params({ limit: 10, offset: 0 })
.getRequest(200)
await expect(articlesResponseTwo).shouldMatchSchema('articles', 'GET_articles')
expect([Link][0].title).[Link]('Hello World')
})
Common extension points
• Add endpoints to [Link] instead of hardcoding URLs.
• Add more fixtures: database client, correlationId header, test data builders.
• Add contract tests: validate schemas for every endpoint.
• Add CI: run tests on PR with junit/allure reporters.
10) How to add a new endpoint test (pattern)
Use this as a template. It shows the minimum pattern: build request, call handler, assert, validate schema.
import { test, expect } from "../utils/fixtures";
test("GET /users returns list of users", async ({ requestHandler }) => {
// 1) Act
const response = await [Link]("/users");
// 2) Assert status
expect([Link]()).toBe(200);
// 3) Parse JSON once
const body = await [Link]();
// 4) Assert shape / fields
expect([Link](body)).toBe(true);
expect(body[0]).toHaveProperty("id");
expect(body[0]).toHaveProperty("name");
// 5) Optional: schema validation (if you have a schema file)
// validateSchema(body, usersSchema);
});
Key principle: keep test logic business-focused; push plumbing (auth, logging, schema validation) into
fixtures and utilities.