Skip to content

API Request Utility with Schema Validation

The API Request utility provides a clean, typed interface for making HTTP requests in Playwright tests with built-in schema validation capabilities. It handles URL construction, header management, response parsing, and single-line response validation with proper TypeScript support.

Features

  • Automatic Retry Logic: Cypress-style retry for server errors (5xx) enabled by default with exponential backoff
  • Strong TypeScript typing for request parameters and responses
  • Four-tier URL resolution strategy (explicit baseUrl, config baseURL, Playwright baseURL, or direct path)
  • Proper handling of URL path normalization and slashes
  • Content-type based response parsing
  • Support for all common HTTP methods
  • Enhanced UI Mode: Visual display with schema validation results
  • 🆕 Schema Validation: Single-line response validation with multiple format support
  • 🆕 Multi-Format Schemas: JSON Schema, YAML files, OpenAPI specifications, Zod schemas

Usage

The utility can be used in two ways:

1. As a Plain Function

typescript
import { apiRequest } from '@seontechnologies/playwright-utils'

// Inside a test or another function
const response = await apiRequest({
  request: context.request, // Playwright request context
  method: 'GET',
  path: '/api/users',
  baseUrl: 'https://api.example.com',
  headers: { Authorization: 'Bearer token' }
})

console.log(response.status) // HTTP status code
console.log(response.body) // Parsed response body

2. As a Playwright Fixture

typescript
// Import the fixture
import { test } from '@seontechnologies/playwright-utils/fixtures'

// Use the fixture in your tests
test('should fetch user data', async ({ apiRequest }) => {
  const { status, body } = await apiRequest<UserResponse>({
    method: 'GET',
    path: '/api/users/123',
    headers: { Authorization: 'Bearer token' }
  })

  // Assertions
  expect(status).toBe(200)
  expect(body.name).toBe('John Doe')
})

API Reference

apiRequest Function

typescript
async function apiRequest<T = unknown>({
  request,
  method,
  path,
  baseUrl,
  configBaseUrl,
  body,
  headers,
  params,
  testStep,
  uiMode,
  retryConfig
}: ApiRequestParams): Promise<ApiRequestResponse<T>>

Parameters

ParameterTypeDescription
requestAPIRequestContextThe Playwright request context
method'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'HTTP method to use
pathstringThe URL path (e.g., '/api/users')
baseUrlstring (optional)Base URL to prepend to the path
configBaseUrlstring (optional)Fallback base URL from Playwright config
bodyunknown (optional)Request body for POST/PUT/PATCH (internally mapped to Playwright's 'data' parameter)
headersRecord<string, string> (optional)HTTP headers
paramsRecord<string, string | boolean | number> (optional)Query parameters
testStepboolean (optional)Whether to wrap the call in test.step() (defaults to true)
uiModeboolean (optional)Enable rich UI display in Playwright UI (defaults to false)
retryConfigApiRetryConfig (optional)Retry configuration for server errors (defaults enabled, set maxRetries: 0 to disable)

retryConfig details (defaults):

typescript
{
  maxRetries: 3,
  initialDelayMs: 100,
  backoffMultiplier: 2,
  maxDelayMs: 5000,
  enableJitter: true, // Adds random jitter to backoff delays
  retryStatusCodes: [500, 502, 503, 504]
}

Return Type

apiRequest returns an enhanced promise that resolves to a response with status and body, plus a chained validateSchema() helper.

typescript
type ApiRequestResponse<T = unknown> = {
  status: number // HTTP status code
  body: T // Response body, typed as T
}

type EnhancedApiResponse<T = unknown> = ApiRequestResponse<T> & {
  validateSchema<TValidated = T>(
    schema: SupportedSchema,
    options?: ValidateSchemaOptions
  ): Promise<ValidatedApiResponse<TValidated>>
}

type EnhancedApiPromise<T = unknown> = Promise<EnhancedApiResponse<T>> & {
  validateSchema<TValidated = T>(
    schema: SupportedSchema,
    options?: ValidateSchemaOptions
  ): Promise<ValidatedApiResponse<TValidated>>
}

Examples

GET Request with Authentication

typescript
import { test } from '@seontechnologies/playwright-utils/fixtures'

test('fetch user profile', async ({ apiRequest }) => {
  const { status, body } = await apiRequest<UserProfile>({
    method: 'GET',
    path: '/api/profile',
    headers: {
      Authorization: 'Bearer token123'
    }
  })

  expect(status).toBe(200)
  expect(body.email).toBeDefined()
})

POST Request with Body

typescript
import { test } from '@seontechnologies/playwright-utils/fixtures'

test('create new item', async ({ apiRequest }) => {
  const { status, body } = await apiRequest<CreateItemResponse>({
    method: 'POST',
    path: '/api/items',
    baseUrl: 'https://api.example.com', // override default baseURL
    body: {
      name: 'New Item',
      price: 19.99
    },
    headers: { 'Content-Type': 'application/json' }
  })

  expect(status).toBe(201)
  expect(body.id).toBeDefined()
})

Handling Query Parameters

typescript
test('demonstrates query parameters', async ({ apiRequest }) => {
  // Query parameters are properly encoded
  const { status, body } = await apiRequest({
    method: 'GET',
    path: '/search',
    params: {
      q: 'search term',
      page: 1,
      active: true
    }
  })
  // Makes a request to /search?q=search%20term&page=1&active=true
})

Handling Different Response Types

typescript
test('handles different response types', async ({ apiRequest }) => {
  // JSON responses are automatically parsed
  const jsonResponse = await apiRequest<UserData>({
    method: 'GET',
    path: '/api/users/1'
  })
  // jsonResponse.body is typed as UserData

  // Text responses are returned as strings
  const textResponse = await apiRequest<string>({
    method: 'GET',
    path: '/api/plain-text',
    headers: {
      Accept: 'text/plain'
    }
  })
  // textResponse.body is a string
})

Using in Non-Test Contexts (Global Setup, Helpers)

typescript
import { apiRequest } from '@seontechnologies/playwright-utils'
import { request } from '@playwright/test'

// For use in global setup or outside of test.step() contexts
async function fetchToken() {
  const requestContext = await request.newContext()

  const { body } = await apiRequest({
    request: requestContext,
    method: 'GET',
    path: '/auth/token',
    baseUrl: 'https://api.example.com',
    testStep: false // Disable test.step wrapping for non-test contexts
  })

  await requestContext.dispose()
  return body.token
}

Retry Logic (Cypress-Style)

The API Request utility includes automatic retry logic that follows Cypress patterns, retrying only server errors (5xx status codes) by default. This helps with transient network issues and temporary server problems while respecting idempotency for client errors.

Default Behavior

  • Enabled by Default: Like Cypress, retry is automatically enabled for all requests
  • Only 5xx Errors: Only retries server errors (500, 502, 503, 504) - never client errors (4xx)
  • Exponential Backoff: Uses exponential backoff with jitter to prevent thundering herd
  • 3 Attempts: Default maximum of 3 retry attempts (total 4 requests)

Retry Configuration

typescript
type ApiRetryConfig = {
  maxRetries?: number // Maximum retry attempts (default: 3)
  initialDelayMs?: number // Initial delay in ms (default: 100)
  backoffMultiplier?: number // Exponential multiplier (default: 2)
  maxDelayMs?: number // Maximum delay cap (default: 5000)
  enableJitter?: boolean // Add random jitter (default: true)
  retryStatusCodes?: number[] // Which codes to retry (default: [500, 502, 503, 504])
}

Retry Examples

Default Retry Behavior

typescript
test('automatic retry for server errors', async ({ apiRequest }) => {
  // Automatically retries 500, 502, 503, 504 errors
  // Never retries 4xx client errors (good for idempotency)
  const { status, body } = await apiRequest({
    method: 'GET',
    path: '/api/users'
    // Retry is enabled by default - no config needed
  })

  expect(status).toBe(200)
})

Disable Retry for Error Testing

typescript
test('test error handling without retry', async ({ apiRequest }) => {
  // Disable retry when you want to test error scenarios
  const { status } = await apiRequest({
    method: 'GET',
    path: '/api/failing-endpoint',
    retryConfig: { maxRetries: 0 } // Explicitly disable retry
  })

  expect(status).toBe(500) // Will fail immediately without retry
})

Custom Retry Configuration

typescript
test('custom retry settings', async ({ apiRequest }) => {
  const { status } = await apiRequest({
    method: 'POST',
    path: '/api/heavy-operation',
    body: { data: 'important' },
    retryConfig: {
      maxRetries: 5, // More attempts for critical operations
      initialDelayMs: 500, // Longer initial delay
      maxDelayMs: 10000, // Higher delay cap
      enableJitter: false // Disable jitter for predictable timing
    }
  })

  expect(status).toBe(201)
})

Why Only 5xx Errors?

Following Cypress and HTTP best practices:

  • 4xx Client Errors (400, 401, 403, 404, etc.): These indicate client-side issues (bad request, unauthorized, not found) that won't be resolved by retrying
  • 5xx Server Errors (500, 502, 503, 504): These indicate temporary server issues that may resolve on retry
typescript
test('demonstrates retry behavior', async ({ apiRequest }) => {
  // These will NOT be retried (fail fast for client errors)
  try {
    await apiRequest({
      method: 'POST',
      path: '/api/users',
      body: { email: 'invalid-email' } // 400 Bad Request - no retry
    })
  } catch (error) {
    // Fails immediately without retry attempts
  }

  // These WILL be retried automatically (server errors)
  const response = await apiRequest({
    method: 'GET',
    path: '/api/sometimes-fails' // May return 503 - will retry with backoff
  })
})

🆕 Schema Validation

Peer Dependencies

Schema validation requires additional dependencies based on your validation needs:

bash
# For JSON Schema validation (using AJV)
npm install ajv

# For Zod schema validation
npm install zod

# For YAML OpenAPI schema files (required for .yaml/.yml files)
npm install js-yaml @types/js-yaml

# Install all dependencies for complete schema support
npm install ajv zod js-yaml @types/js-yaml

Why peer dependencies? These validation libraries are marked as optional peer dependencies to:

  • Give you control over which validation libraries to include in your bundle
  • Allow you to choose specific versions that work with your project
  • Avoid unnecessary bundle size if you only need specific validation types

When each dependency is needed:

  • ajv - Required for JSON Schema validation
  • zod - Required for Zod schema validation
  • js-yaml - Required for YAML OpenAPI file loading (.yaml/.yml files)

Error handling: If you attempt to use schema validation without the required dependency installed, you'll get a clear error message indicating which package to install.

Import Options

The validateSchema function can be imported in multiple ways depending on your use case:

1. As a Plain Function (Non-Fixture)

Use this when you need schema validation outside of Playwright test context, such as in helper utilities, global setup, or standalone scripts:

typescript
import {
  validateSchema,
  detectSchemaFormat,
  ValidationError
} from '@seontechnologies/playwright-utils/api-request/schema-validation'

// Plain function signature: (data, schema, options)
const result = await validateSchema(responseBody, MySchema, {
  shape: { status: 200 }
})

if (result.success) {
  console.log('Validation passed')
} else {
  console.log('Validation errors:', result.errors)
}

// Detect schema format (Zod, JSON Schema, OpenAPI, etc.)
const format = detectSchemaFormat(MySchema) // Returns 'Zod Schema' | 'JSON Schema' | 'JSON OpenAPI' | 'YAML OpenAPI'

Note: The plain function uses (data, schema, options) parameter order, while the fixture uses (schema, data, options). This allows the fixture to mirror the chained API pattern.

Available exports from this path:

  • validateSchema - Core validation function with signature (data, schema, options)
  • detectSchemaFormat - Detect schema type automatically
  • ValidationError - Error class for validation failures
  • Types: SupportedSchema, ValidationMode, ValidateSchemaOptions, ValidationResult, etc.

2. As a Playwright Fixture

Use this within Playwright tests for seamless integration with test reporting.

Note: The fixture uses (schema, data, options) parameter order to match the chained API pattern.

typescript
// Option A: Using merged fixtures (recommended)
import { test } from '@seontechnologies/playwright-utils/fixtures'

test('validate response', async ({ apiRequest, validateSchema }) => {
  const { body } = await apiRequest({ method: 'GET', path: '/api/data' })
  // Fixture signature: (schema, data, options)
  await validateSchema(MySchema, body, { shape: { status: 200 } })
})
typescript
// Option B: Using mergeTests with your own fixtures
import { mergeTests } from '@playwright/test'
import { test as validateSchemaFixture } from '@seontechnologies/playwright-utils/api-request/schema-validation'
import { test as myFixtures } from './my-fixtures'

export const test = mergeTests(myFixtures, validateSchemaFixture)

test('validate response', async ({ validateSchema }) => {
  // validateSchema fixture is available
})

3. Chained API (via apiRequest)

The most common pattern - chain .validateSchema() directly on apiRequest calls:

typescript
import { test } from '@seontechnologies/playwright-utils/fixtures'

test('chained validation', async ({ apiRequest }) => {
  const response = await apiRequest({
    method: 'GET',
    path: '/api/data'
  }).validateSchema(MySchema, { shape: { status: 200 } })
})

Import Summary

Import PathTypeUse Case
@seontechnologies/playwright-utils/api-request/schema-validationPlain functionHelpers, utilities, non-test code
@seontechnologies/playwright-utils/fixturesMerged fixtureStandard Playwright tests
@seontechnologies/playwright-utils/api-request/fixturesStandalone fixtureCustom mergeTests setups

Quick Start - Schema Validation

Reduce 5-10 lines of manual validation to a single line with built-in schema validation:

Note: Examples below use the merged fixtures import. See Import Options for all available import patterns including plain function usage.

typescript
import { test, expect } from '@seontechnologies/playwright-utils/fixtures'
import { CreateMovieResponseSchema } from '../../../sample-app/shared/types/schema'

test('schema validation basics', async ({
  apiRequest,
  authToken,
  validateSchema
}) => {
  const movieData = {
    name: 'Test Movie',
    year: 2024,
    rating: 8.5,
    director: 'Test Director'
  }

  // Traditional approach: Multiple manual assertions
  const response = await apiRequest({
    method: 'POST',
    path: '/movies',
    body: movieData,
    headers: { Cookie: `seon-jwt=${authToken}` }
  })
  expect(response.status).toBe(200)
  expect(response.body.data.name).toBe('Test Movie')
  expect(response.body.data.id).toBeDefined()
  // ... more assertions

  // NEW: Single-line schema validation with Zod
  const validatedResponse = await apiRequest({
    method: 'POST',
    path: '/movies',
    body: movieData,
    headers: { Cookie: `seon-jwt=${authToken}` }
  }).validateSchema(CreateMovieResponseSchema, {
    shape: { status: 200, data: { name: 'Test Movie' } }
  })
  // Fixture style:
  // const { body } = await apiRequest({ ... })
  // await validateSchema(CreateMovieResponseSchema, body, { shape: { ... } })

  // Type assertion needed for accessing response data
  const responseBody = validatedResponse.body as {
    status: number
    data: { id: string; name: string }
  }

  // Response is guaranteed valid with proper typing
  expect(responseBody.data.id).toBeDefined()
  expect(responseBody.data.name).toBe('Test Movie')
})

TypeScript note: schema validation verifies runtime shape but does not infer a compile-time type for response.body. The examples use inline assertions for clarity; consider a shared response type (for example z.infer<typeof Schema>) or a typed helper that wraps validateSchema<TValidated>() to reduce repetition.

Multi-Format Schema Support

JSON Schema

typescript
test('JSON Schema validation basics', async ({ apiRequest, authToken }) => {
  const movieData = {
    name: 'Test Movie',
    year: 2024,
    rating: 8.5,
    director: 'Test Director'
  }

  // Define JSON schema directly
  const jsonSchema = {
    type: 'object',
    properties: {
      status: { type: 'number' },
      data: {
        type: 'object',
        properties: {
          id: { type: 'number' },
          name: { type: 'string' },
          year: { type: 'number' },
          rating: { type: 'number' },
          director: { type: 'string' }
        },
        required: ['id', 'name', 'year', 'rating', 'director']
      }
    },
    required: ['status', 'data']
  }

  const response = await apiRequest({
    method: 'POST',
    path: '/movies',
    body: movieData,
    headers: { Cookie: `seon-jwt=${authToken}` }
  }).validateSchema(jsonSchema, {
    shape: {
      status: 200,
      data: {
        name: movieData.name,
        year: movieData.year,
        rating: movieData.rating,
        director: movieData.director
      }
    }
  })
  // Fixture style:
  // const { body } = await apiRequest({ ... })
  // await validateSchema(jsonSchema, body, { shape: { ... } })

  // Type assertion for accessing response data
  const responseBody = response.body as {
    status: number
    data: { id: string; name: string }
  }

  // Response is guaranteed valid and type-safe
  expect(responseBody.data.id).toBeDefined()
  expect(responseBody.data.name).toBe(movieData.name)
})
typescript
test('JSON Schema validation with awaited helper', async ({
  addMovie,
  authToken,
  validateSchema
}) => {
  const { body } = await addMovie(authToken, movie)

  await validateSchema(jsonSchema, body, {
    shape: {
      status: 200,
      data: {
        name: movie.name,
        year: movie.year,
        rating: movie.rating,
        director: movie.director
      }
    }
  })
})

Chained vs Helper Schema Validation

Both .validateSchema() and the standalone validateSchema() helper share the same validation engine. Choose the style that fits how you obtain your response:

  • Chained — works great when you call apiRequest() directly and want a fluent API.
  • Helper — ideal when a fixture wraps apiRequest() (for example with functionTestStep) or when you already have the { status, body } pair from another helper.
typescript
// Chained style (fluent)
const { body } = await apiRequest({
  method: 'GET',
  path: '/movies/123'
}).validateSchema(GetMovieResponseUnionSchema)

// Awaited style (fixture-friendly)
const { body } = await getMovieById(authToken, movieId)
await validateSchema(GetMovieResponseUnionSchema, body, {
  shape: {
    status: 200,
    data: expect.objectContaining({ id: movieId })
  }
})

Tip: Both styles return the same object, support every validateSchema option, and can be mixed within the same test suite.

Zod Schema Integration

typescript
import { CreateMovieResponseSchema } from '../../../sample-app/shared/types/schema'

test('Zod schema validation with TypeScript inference', async ({
  apiRequest,
  authToken
}) => {
  const movieData = {
    name: 'Test Movie',
    year: 2024,
    rating: 8.5,
    director: 'Test Director'
  }

  const response = await apiRequest({
    method: 'POST',
    path: '/movies',
    body: movieData,
    headers: { Cookie: `seon-jwt=${authToken}` }
  }).validateSchema(CreateMovieResponseSchema)
  // Fixture style:
  // const { body } = await apiRequest({ ... })
  // await validateSchema(CreateMovieResponseSchema, body)

  // Response is guaranteed valid with proper typing
  expect(response.body.data.id).toBeDefined()
  expect(response.body.data.name).toBe(movieData.name)
  expect(response.body.status).toBe(200)
})
typescript
test('Zod schema validation with awaited helper', async ({
  getMovieById,
  authToken,
  validateSchema
}) => {
  const { body } = await getMovieById(authToken, movieId)

  await validateSchema(CreateMovieResponseSchema, body, {
    shape: {
      status: 200,
      data: expect.objectContaining({ id: movieId })
    }
  })
})

OpenAPI Specification Support

typescript
import openApiJson from '../../../sample-app/backend/src/api-docs/openapi.json'

test('OpenAPI JSON specification validation', async ({
  apiRequest,
  authToken
}) => {
  const response = await apiRequest({
    method: 'POST',
    path: '/movies',
    body: movieData,
    headers: { Cookie: `seon-jwt=${authToken}` }
  }).validateSchema(openApiJson, {
    endpoint: '/movies',
    method: 'POST',
    status: 200,
    shape: {
      status: 200,
      data: {
        name: movieData.name,
        year: movieData.year
      }
    }
  })
  // Fixture style:
  // const { body } = await apiRequest({ ... })
  // await validateSchema(openApiJson, body, { shape: { ... } })

  // Type assertion for accessing response data
  const responseBody = response.body as {
    status: number
    data: { id: string; name: string }
  }

  expect(responseBody.data.id).toBeDefined()
  expect(responseBody.data.name).toBe(movieData.name)
})

When you want to assert on validationResult, capture the returned object from the helper:

typescript
import openApiJson from '../../../sample-app/backend/src/api-docs/openapi.json'

test('awaited OpenAPI JSON validation', async ({
  apiRequest,
  authToken,
  validateSchema
}) => {
  const { body } = await apiRequest({
    method: 'POST',
    path: '/movies',
    body: movieData,
    headers: { Cookie: `seon-jwt=${authToken}` }
  })

  const result = await validateSchema(openApiJson, body, {
    endpoint: '/movies',
    method: 'POST',
    status: 200,
    shape: {
      status: 200,
      data: expect.objectContaining({ name: movieData.name })
    }
  })

  expect(result.validationResult.success).toBe(true)
})
typescript
test('OpenAPI YAML file validation', async ({ apiRequest, authToken }) => {
  const response = await apiRequest({
    method: 'POST',
    path: '/movies',
    body: movieData,
    headers: { Cookie: `seon-jwt=${authToken}` }
  }).validateSchema('./api-docs/openapi.yml', {
    path: '/movies', // 'path' and 'endpoint' are interchangeable
    method: 'POST',
    status: 200
  })
  // Fixture style:
  // const { body } = await apiRequest({ ... })
  // await validateSchema('./api-docs/openapi.yml', body, {
  //   path: '/movies',
  //   method: 'POST',
  //   status: 200
  // })

  const responseBody = response.body as {
    status: number
    data: { id: string }
  }

  expect(responseBody.data.id).toBeDefined()
})

Schema-Only Validation (No Shape Assertions)

typescript
import { GetMovieResponseUnionSchema } from '../../../sample-app/shared/types/schema'

test('schema validation without shape assertions', async ({
  apiRequest,
  authToken
}) => {
  // Schema-only validation - options parameter is optional
  const response = await apiRequest({
    method: 'GET',
    path: `/movies/123`,
    headers: { Cookie: `seon-jwt=${authToken}` }
  }).validateSchema(GetMovieResponseUnionSchema)
  // Fixture style:
  // const { body } = await apiRequest({ ... })
  // await validateSchema(GetMovieResponseUnionSchema, body)

  // Type assertion for accessing response data
  const responseBody = response.body as {
    status: number
    data: unknown
  }

  // Only schema compliance is validated, no additional shape assertions
  expect(responseBody.status).toBe(200)
  expect(responseBody.data).toBeDefined()
})
typescript
test('return mode validation with awaited helper', async ({
  apiRequest,
  authToken,
  validateSchema
}) => {
  const { body } = await apiRequest({
    method: 'POST',
    path: '/movies',
    body: movieData,
    headers: { Cookie: `seon-jwt=${authToken}` }
  })

  const result = await validateSchema('./api-docs/openapi.yml', body, {
    path: '/movies',
    method: 'POST',
    status: 200,
    mode: 'return'
  })

  expect(result.validationResult.success).toBe(true)
  expect(result.validationResult.errors).toBeUndefined()
})

Return Mode (Non-Throwing Validation)

typescript
import { z } from 'zod'

test('return mode validation - does not throw on failure', async ({
  apiRequest,
  authToken
}) => {
  const response = await apiRequest({
    method: 'POST',
    path: '/movies',
    body: movieData,
    headers: { Cookie: `seon-jwt=${authToken}` }
  }).validateSchema(
    z.object({
      status: z.literal(999), // This will fail - API returns 200
      data: z.any()
    }),
    {
      mode: 'return' // Don't throw on failure
    }
  )
  // Fixture style:
  // const { body } = await apiRequest({ ... })
  // await validateSchema(z.object({ ... }), body, { mode: 'return' })

  // Response indicates validation failure but doesn't throw
  expect(response.validationResult.success).toBe(false)
  expect(response.validationResult.errors).toBeDefined()
  expect(response.validationResult.errors.length).toBeGreaterThan(0)

  // Original response data is still accessible
  const responseBody = response.body as {
    status: number
    data: unknown
  }
  expect(responseBody.status).toBe(200)
  expect(responseBody.data).toBeDefined()
})

Advanced Shape Validation with Functions

typescript
import { CreateMovieResponseSchema } from '../../../sample-app/shared/types/schema'

test('combined schema + shape validation with functions', async ({
  apiRequest,
  authToken
}) => {
  const response = await apiRequest({
    method: 'POST',
    path: '/movies',
    body: movieData,
    headers: { Cookie: `seon-jwt=${authToken}` }
  }).validateSchema(CreateMovieResponseSchema, {
    shape: {
      status: 200,
      data: {
        name: (name: string) => name.length > 0,
        year: (year: number) =>
          year >= 1900 && year <= new Date().getFullYear(),
        rating: (rating: number) => rating >= 0 && rating <= 10,
        id: (id: string) => typeof id === 'string'
      }
    }
  })
  // Fixture style:
  // const { body } = await apiRequest({ ... })
  // await validateSchema(CreateMovieResponseSchema, body, { shape: { ... } })

  // Type assertion for accessing response data
  const responseBody = response.body as {
    status: number
    data: { name: string; year: number }
  }

  // Both schema compliance AND shape assertions pass
  expect(responseBody.data.name).toBe(movieData.name)
  expect(responseBody.data.year).toBe(movieData.year)
})
typescript
test('awaited helper with shape functions', async ({
  updateMovie,
  authToken,
  validateSchema
}) => {
  const { body } = await updateMovie(authToken, movieId, updatedMovie)

  await validateSchema(CreateMovieResponseSchema, body, {
    shape: {
      status: 200,
      data: {
        name: (name: string) => name.length > 0,
        rating: (rating: number) => rating >= 0 && rating <= 10
      }
    }
  })
})

URL Resolution Strategy

Note: The apiRequest utility follows a priority order for resolving URLs:

  1. Explicit baseUrl parameter in the function call
  2. configBaseUrl parameter in the function call
  3. Playwright config's baseURL from your playwright.config.ts file
  4. Absolute URLs in the path parameter are used as-is

UI Mode for API E2E Testing

Enables rich visual feedback for API requests in Playwright UI with formatted request/response details, duration tracking, and status color coding.

Enable UI Mode

Environment Variable (Recommended):

typescript
// In config or at top of test file
process.env.API_E2E_UI_MODE = 'true'

Per-Request:

typescript
const response = await apiRequest({
  method: 'GET',
  path: '/api/movies',
  uiMode: true
})

Example

typescript
process.env.API_E2E_UI_MODE = 'true'

test('API test with UI display', async ({ apiRequest }) => {
  const { status, body } = await apiRequest({
    method: 'POST',
    path: '/api/movies',
    body: { name: 'Test Movie', year: 2023 }
  })

  expect(status).toBe(201)
})

Real-World Examples

CRUD Operations with Typed Fixtures

The API request utility shines when used with fixtures for CRUD operations. This example shows a real implementation using proper typing and SEON's functional approach:

typescript
// From playwright/support/fixtures/crud-helper-fixture.ts
export const test = baseApiRequestFixture.extend<CrudParams>({
  // Create movie API fixture with proper typing
  addMovie: async ({ apiRequest }, use) => {
    const addMovieBase = async (
      token: string,
      body: Omit<Movie, 'id'>,
      baseUrl?: string
    ) =>
      apiRequest<CreateMovieResponse>({
        method: 'POST',
        path: '/movies',
        baseUrl,
        body,
        headers: { Authorization: token }
      })

    // Enhanced with test step for better reporting
    const addMovie = functionTestStep('Add Movie', addMovieBase)
    await use(addMovie)
  },

  // Get movie by ID with proper typing
  getMovieById: async ({ apiRequest }, use) => {
    const getMovieByIdBase = async (
      token: string,
      id: string,
      baseUrl?: string
    ) =>
      apiRequest<GetMovieResponse>({
        method: 'GET',
        path: `/movies/${id}`,
        baseUrl,
        headers: { Authorization: token }
      })

    const getMovieById = functionTestStep('Get Movie By ID', getMovieByIdBase)
    await use(getMovieById)
  },

  // Additional operations follow the same pattern
  updateMovie: async ({ apiRequest }, use) => {
    // Implementation with proper typing and test step decoration
    await use(functionTestStep('Update Movie', updateMovieBase))
  },

  deleteMovie: async ({ apiRequest }, use) => {
    // Implementation with proper typing and test step decoration
    await use(functionTestStep('Delete Movie', deleteMovieBase))
  }
})

Usage in Tests - Traditional vs Schema Validation

Real examples showing both approaches from the CRUD tests:

typescript
// From playwright/tests/sample-app/backend/crud-movie-event.spec.ts
test('should perform CRUD operations with schema validation', async ({
  addMovie,
  getAllMovies,
  getMovieById,
  updateMovie,
  deleteMovie,
  authToken
}) => {
  const movie = generateMovieWithoutId()
  const updatedMovie = generateMovieWithoutId()

  // Create movie with BOTH schema validation AND traditional assertions
  const { body: createResponse, status: createStatus } = await addMovie(
    authToken,
    movie
  ).validateSchema(CreateMovieResponseSchema, {
    shape: {
      status: 200,
      data: { ...movieProps, id: expect.any(String) }
    }
  })

  // Traditional assertions kept for comparison - with validateSchema we get BOTH:
  // 1. Schema validation (above) + 2. Traditional assertions (below) if desired
  expect(createStatus).toBe(200)
  expect(createResponse).toMatchObject({
    status: 200,
    data: { ...movieProps, id: movieId }
  })

  const movieId = createResponse.data.id

  // Get all movies with schema validation
  const { body: getAllResponse, status: getAllStatus } = await getAllMovies(
    authToken
  ).validateSchema(GetMovieResponseUnionSchema, {
    shape: {
      status: 200,
      data: expect.arrayContaining([
        expect.objectContaining({ id: movieId, name: movie.name })
      ])
    }
  })
  // classic assertions: we can do either the above or the below
  expect(getAllResponse).toMatchObject({
    status: 200,
    data: expect.arrayContaining([
      expect.objectContaining({ id: movieId, name: movie.name })
    ])
  })
  expect(getAllStatus).toBe(200)

  // Get movie by ID with schema-only validation (no shape assertions)
  const { body: getByIdResponse, status: getByIdStatus } = await getMovieById(
    authToken,
    movieId
  ).validateSchema(GetMovieResponseUnionSchema)

  // Traditional assertions can coexist with schema validation
  expect(getByIdStatus).toBe(200)
  expect(getByIdResponse).toMatchObject({
    status: 200,
    data: { ...movieProps, id: movieId }
  })

  // Update movie with schema validation
  const { body: updateResponse, status: updateStatus } = await updateMovie(
    authToken,
    movieId,
    updatedMovie
  ).validateSchema(UpdateMovieResponseSchema, {
    shape: {
      status: 200,
      data: {
        id: movieId,
        name: updatedMovie.name,
        year: updatedMovie.year,
        rating: updatedMovie.rating,
        director: updatedMovie.director
      }
    }
  })
  // classic assertions: we can do either the above or the below
  expect(updateStatus).toBe(200)

  // Delete with schema validation
  const { status: deleteStatus, body: deleteResponseBody } = await deleteMovie(
    authToken,
    movieId
  ).validateSchema(DeleteMovieResponseSchema, {
    shape: {
      message: `Movie ${movieId} has been deleted`
    }
  })
  expect(deleteStatus).toBe(200)
  expect(deleteResponseBody.message).toBe(`Movie ${movieId} has been deleted`)

  // Verify movie no longer exists with schema validation
  await getAllMovies(authToken).validateSchema(GetMovieResponseUnionSchema, {
    shape: {
      status: 200,
      data: expect.not.arrayContaining([
        expect.objectContaining({ id: movieId })
      ])
    }
  })
})

Benefits of this Pattern

This approach offers several advantages aligned with SEON's development principles:

  • Type Safety: Full TypeScript support through generics
  • Reusability: Fixtures are reusable across all test files
  • Function Composition: Enhanced with logging via functionTestStep
  • Clean Separation: API client logic is separate from test logic
  • Maintainability: Changes to endpoints only need to be updated in one place
  • Readability: Tests clearly express intent without implementation details

Integration with Auth Session

The API request utility works seamlessly with the Auth Session manager:

typescript
test('should use cached auth token', async ({
  apiRequest,
  authToken // From auth session fixture
}) => {
  // The authToken is retrieved from cache if available
  // Only fetched from API if needed/invalid
  const { status, body } = await apiRequest({
    method: 'GET',
    path: '/api/protected-resource',
    headers: {
      Authorization: `Bearer ${authToken}`
    }
  })

  expect(status).toBe(200)
})

Working with Async Operations and Polling

Combining with the recurse utility for polling async operations:

typescript
test('should wait for resource creation', async ({
  apiRequest,
  authToken,
  recurse
}) => {
  // Create a resource that triggers an async process
  const { body: createResponse } = await apiRequest({
    method: 'POST',
    path: '/api/resources',
    body: { name: 'Async Resource' },
    headers: { Authorization: `Bearer ${authToken}` }
  })

  const resourceId = createResponse.id

  // Poll until the resource is in the desired state
  await recurse(
    async () => {
      const { body } = await apiRequest({
        method: 'GET',
        path: `/api/resources/${resourceId}`,
        headers: { Authorization: `Bearer ${authToken}` }
      })

      // Can use assertions directly in the predicate
      expect(body.status).toBe('COMPLETED')
    },
    {
      interval: 1000,
      timeout: 30000,
      timeoutMessage: `Resource ${resourceId} did not complete in time`
    }
  )
})

Released under the MIT License.