Skip to content

REST API: Code Examples (Python + Pydantic + Playwright)

Practical code examples for REST API testing. Each section shows a pattern you can copy into your project.

1. Pydantic Models for API Validation

Define strict input/output schemas. Use extra = "forbid" to catch unexpected fields early.

from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
from uuid import UUID
from enum import Enum

class UserStatus(str, Enum):
    active = "active"
    inactive = "inactive"
    pending = "pending"

class UserCreate(BaseModel):
    """Input schema - no id, no timestamps."""
    name: str = Field(min_length=2, max_length=100)
    email: str = Field(pattern=r'^[\w.+-]+@[\w-]+\.[\w.]+$')
    age: Optional[int] = Field(default=None, ge=0, le=150)
    status: UserStatus = UserStatus.pending
    model_config = {"extra": "forbid"}

class UserResponse(BaseModel):
    """Output schema - includes server-generated fields."""
    id: UUID
    name: str
    email: str
    age: Optional[int] = None
    status: UserStatus
    created_at: datetime
    updated_at: datetime

class ErrorDetail(BaseModel):
    field: str
    message: str
    code: str

class ErrorResponse(BaseModel):
    code: str
    message: str
    request_id: Optional[str] = None
    details: list[ErrorDetail] = Field(default_factory=list)

class PaginatedResponse(BaseModel):
    data: list[UserResponse]
    total: int
    limit: int
    offset: int
    next: Optional[str] = None
    prev: Optional[str] = None

2. Playwright API Client Setup (pytest fixture)

Create a reusable API context with base URL and auth headers. The fixture lives for the whole test session.

import pytest
from playwright.sync_api import Playwright, APIRequestContext

BASE_URL = "https://api.example.com/v1"

@pytest.fixture(scope="session")
def api_context(playwright: Playwright):
    """Create API context with auth headers."""
    context = playwright.request.new_context(
        base_url=BASE_URL,
        extra_http_headers={
            "Content-Type": "application/json",
            "Authorization": "Bearer test-token-123",
        },
    )
    yield context
    context.dispose()

3. Test: CRUD Operations

Basic create and read tests. Validate each response against a Pydantic model to catch schema changes.

def test_create_user(api_context):
    """Test POST /users creates a user and validates response schema."""
    payload = {"name": "John Doe", "email": "john@example.com", "age": 30}
    response = api_context.post("/users", data=payload)
    assert response.status == 201
    user = UserResponse.model_validate(response.json())
    assert user.name == "John Doe"

def test_get_user(api_context):
    """Test GET /users/{id} returns valid user."""
    response = api_context.get("/users/550e8400-e29b-41d4-a716-446655440000")
    assert response.status == 200
    user = UserResponse.model_validate(response.json())
    assert user.id is not None

4. Test: Schema Validation

Check that the server rejects bad input with proper error codes and messages.

def test_reject_unknown_fields(api_context):
    """Server should reject request with unknown fields."""
    payload = {"name": "John", "email": "john@test.com", "role": "admin"}
    response = api_context.post("/users", data=payload)
    assert response.status == 422
    error = ErrorResponse.model_validate(response.json())
    assert error.code == "VALIDATION_ERROR"

def test_required_fields_missing(api_context):
    """Server should return error when required field is missing."""
    payload = {"name": "John"}  # missing email
    response = api_context.post("/users", data=payload)
    assert response.status == 422

def test_invalid_email_format(api_context):
    payload = {"name": "John", "email": "not-an-email"}
    response = api_context.post("/users", data=payload)
    assert response.status == 422

5. Test: Pagination

Verify offset-based pagination metadata and edge cases like requesting beyond the dataset.

def test_pagination_offset(api_context):
    """Test offset-based pagination returns correct metadata."""
    response = api_context.get("/users?limit=10&offset=0")
    assert response.status == 200
    page = PaginatedResponse.model_validate(response.json())
    assert page.limit == 10
    assert page.offset == 0
    assert len(page.data) <= 10

def test_pagination_beyond_dataset(api_context):
    """Offset beyond total should return empty list."""
    response = api_context.get("/users?limit=10&offset=999999")
    assert response.status == 200
    page = PaginatedResponse.model_validate(response.json())
    assert len(page.data) == 0

6. Test: Caching and Concurrency

Use ETags for conditional requests and optimistic locking to prevent lost updates.

def test_etag_caching(api_context):
    """Test ETag-based conditional requests."""
    response = api_context.get("/users/550e8400-e29b-41d4-a716-446655440000")
    etag = response.headers.get("etag")
    assert etag is not None
    cached = api_context.get(
        "/users/550e8400-e29b-41d4-a716-446655440000",
        headers={"If-None-Match": etag},
    )
    assert cached.status == 304

def test_optimistic_locking(api_context):
    """Test concurrent update conflict detection."""
    response = api_context.get("/users/550e8400-e29b-41d4-a716-446655440000")
    etag = response.headers.get("etag")
    update1 = api_context.put(
        "/users/550e8400-e29b-41d4-a716-446655440000",
        data={"name": "Updated"},
        headers={"If-Match": etag},
    )
    assert update1.status == 200
    update2 = api_context.put(
        "/users/550e8400-e29b-41d4-a716-446655440000",
        data={"name": "Conflict"},
        headers={"If-Match": etag},
    )
    assert update2.status == 412  # RFC 7232: If-Match failure = 412 Precondition Failed

7. Test: Idempotency

Use an Idempotency-Key header to make POST requests safe to retry without creating duplicates.

import uuid

def test_post_idempotency_key(api_context):
    """Same idempotency key should return same result."""
    key = str(uuid.uuid4())
    payload = {"name": "Jane", "email": "jane@test.com"}
    headers = {"Idempotency-Key": key}
    resp1 = api_context.post("/users", data=payload, headers=headers)
    resp2 = api_context.post("/users", data=payload, headers=headers)
    assert resp1.status == 201
    assert resp2.status == 201
    assert resp1.json()["id"] == resp2.json()["id"]