0% found this document useful (0 votes)
8 views17 pages

Playwright API Testing Framework Step by Step Explained

This document outlines a Playwright-based API testing framework in TypeScript, detailing its structure, execution flow, and key components such as configuration files, fixtures, and request handling. It provides instructions for running tests locally and includes information on authentication, JSON schema validation, logging, and custom assertions. The framework aims to streamline API testing by encapsulating common functionalities and ensuring consistency across tests.

Uploaded by

voraparshva3
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
8 views17 pages

Playwright API Testing Framework Step by Step Explained

This document outlines a Playwright-based API testing framework in TypeScript, detailing its structure, execution flow, and key components such as configuration files, fixtures, and request handling. It provides instructions for running tests locally and includes information on authentication, JSON schema validation, logging, and custom assertions. The framework aims to streamline API testing by encapsulating common functionalities and ensuring consistency across tests.

Uploaded by

voraparshva3
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd

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.

You might also like