Skip to content

API Test Patterns: REST and GraphQL

REST

Request Builder

Encapsulate request construction. Keep tests readable and DRY.

class RestRequestBuilder:
    def __init__(self, client: httpx.Client) -> None:
        self._client = client
        self._path = "/"
        self._params: dict = {}
        self._headers: dict = {}
        self._body: dict | None = None

    def get(self, path: str) -> "RestRequestBuilder":
        self._path = path
        self._method = "GET"
        return self

    def post(self, path: str) -> "RestRequestBuilder":
        self._path = path
        self._method = "POST"
        return self

    def with_param(self, key: str, value) -> "RestRequestBuilder":
        self._params[key] = value
        return self

    def with_body(self, body: dict) -> "RestRequestBuilder":
        self._body = body
        return self

    def send(self) -> httpx.Response:
        method = getattr(self._client, self._method.lower())
        kwargs: dict = {"params": self._params, "headers": self._headers}
        if self._body is not None:
            kwargs["json"] = self._body
        return method(self._path, **kwargs)

Response Validator

Centralize contract checks on responses:

import jsonschema


class RestResponseValidator:
    def __init__(self, response: httpx.Response) -> None:
        self._response = response

    def expect_status(self, code: int) -> "RestResponseValidator":
        assert self._response.status_code == code, (
            f"Expected {code}, got {self._response.status_code}\n{self._response.text}"
        )
        return self

    def expect_schema(self, schema: dict) -> "RestResponseValidator":
        jsonschema.validate(self._response.json(), schema)
        return self

    def expect_header(self, key: str, value: str) -> "RestResponseValidator":
        assert self._response.headers.get(key) == value
        return self

    def json(self) -> dict:
        return self._response.json()

Schema Validation

Validate every response against an OpenAPI-derived JSON Schema:

import jsonschema
import pathlib
import json

SCHEMAS = json.loads(pathlib.Path("tests/schemas/api.json").read_text())

def validate_response(response: httpx.Response, schema_name: str) -> None:
    jsonschema.validate(response.json(), SCHEMAS[schema_name])

Test Checklist for REST

Check Description
Status code Correct code for each scenario
Schema Response body matches schema
Error format RFC 9457 Problem Details structure
Headers Content-Type, ETag, Cache-Control present
Pagination items, total, page, size fields
Idempotency Repeated PUT/DELETE returns same result
Auth 401 without token, 403 with wrong role

GraphQL

Query Builder

class GraphQLQueryBuilder:
    def __init__(self, client: httpx.Client, url: str = "/graphql") -> None:
        self._client = client
        self._url = url

    def query(self, query_str: str, variables: dict | None = None) -> dict:
        payload: dict = {"query": query_str}
        if variables:
            payload["variables"] = variables
        response = self._client.post(self._url, json=payload)
        assert response.status_code == 200, f"GraphQL transport error: {response.text}"
        body = response.json()
        assert "errors" not in body, f"GraphQL errors: {body['errors']}"
        return body["data"]

    def query_with_errors(self, query_str: str, variables: dict | None = None) -> dict:
        payload: dict = {"query": query_str}
        if variables:
            payload["variables"] = variables
        response = self._client.post(self._url, json=payload)
        return response.json()

Schema Validation for GraphQL

Validate against introspection schema:

from gql import Client, gql
from gql.transport.httpx import HTTPXTransport

def make_gql_client(url: str, token: str) -> Client:
    transport = HTTPXTransport(
        url=url,
        headers={"Authorization": f"Bearer {token}"},
    )
    return Client(transport=transport, fetch_schema_from_transport=True)

Resolver-Level Testing

Test individual resolver behaviour:

Resolver scenario Test
Field returns null Query field, assert null
N+1 detection Count DB queries via middleware
Auth on field Query with insufficient role, expect error
Pagination first/after args return correct slice

Test Checklist for GraphQL

Check Description
HTTP 200 always GraphQL errors are in body, not status
errors field absent on success No partial error states
errors[].extensions.code Machine-readable error codes present
Depth limit Query exceeding max depth returns error
Complexity limit Overly expensive query rejected
Introspection disabled in prod Security check