Skip to content

Why basst?

Testing REST APIs in Python means stitching together httpx calls, status checks, JSON parsing, and Pydantic model construction. basst collapses that into a fluent, chainable API.

All examples below assume this model:

from pydantic import BaseModel


class User(BaseModel):
    id: int
    name: str
    email: str

Scenario 1 -- Basic GET with model binding

Plain httpx + Pydantic

import httpx
from pydantic import ValidationError

response = httpx.get("https://api.example.com/users/1")

assert response.status_code == 200, (
    f"Expected 200, got {response.status_code}: {response.text}"
)

try:
    user = User(**response.json())
except ValidationError as exc:
    raise AssertionError(f"Response did not match User schema:\n{exc}") from exc

assert user.name == "Ada"
assert "@" in user.email

Manual status check, manual JSON parsing, try/except for validation. A bare assert response.status_code == 200 gives no URL or body on failure.

With basst

from basst import Client

api = Client("https://api.example.com")
user = api.get("/users/1").expect().status(200).model(User)

assert user.name == "Ada"
assert "@" in user.email

Scenario 2 -- POST with JSON body and header assertions

Plain httpx + Pydantic

import re
import httpx
from pydantic import ValidationError

response = httpx.post(
    "https://api.example.com/users",
    json={"name": "Ada", "email": "ada@example.com"},
    headers={"X-Request-Id": "abc123"},
)

assert response.status_code == 201, (
    f"Expected 201, got {response.status_code}: {response.text}"
)

assert "x-request-id" in response.headers, "Missing x-request-id header"
request_id = response.headers["x-request-id"]
assert re.match(r"^[a-f0-9-]{36}$", request_id), (
    f"Expected x-request-id to be a UUID, got: {request_id}"
)

try:
    user = User(**response.json())
except ValidationError as exc:
    raise AssertionError(f"Response did not match User schema:\n{exc}") from exc

assert user.name == "Ada"

Every assertion needs a hand-rolled error message. Header pattern matching takes three lines.

With basst

from basst import Client, matchers

api = Client("https://api.example.com")

user = (
    api.post("/users")
    .json({"name": "Ada", "email": "ada@example.com"})
    .header("X-Request-Id", "abc123")
    .expect()
    .status(201)
    .header("x-request-id", matchers.matches(r"^[a-f0-9-]{36}$"))
    .model(User)
)

assert user.name == "Ada"

Scenario 3 -- Error handling when the server misbehaves

Plain httpx + Pydantic

import httpx

response = httpx.get("https://api.example.com/users/1")
assert response.status_code == 200

# Blows up with:
#   json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
# No URL, no status code, no response body in the traceback.
data = response.json()
user = User(**data)

With basst

from basst import Client

api = Client("https://api.example.com")

# Raises:
#   basst.ContentTypeError: Expected content-type application/json or *+json, got text/html
#     GET https://api.example.com/users/1
#     Response body: <html><body>502 Bad Gateway</body></html>
user = api.get("/users/1").expect().status(200).model(User)

basst validates content-type before parsing JSON. The error includes method, URL, and response body.


Scenario 4 -- Collection endpoint with query parameters

Plain httpx + Pydantic

import httpx
from pydantic import ValidationError

response = httpx.get(
    "https://api.example.com/users",
    params={"page": 1, "limit": 10},
)

assert response.status_code == 200, (
    f"Expected 200, got {response.status_code}: {response.text}"
)

data = response.json()
assert isinstance(data, list), f"Expected list, got {type(data).__name__}"

users: list[User] = []
for i, item in enumerate(data):
    try:
        users.append(User(**item))
    except ValidationError as exc:
        raise AssertionError(
            f"Item {i} did not match User schema:\n{exc}"
        ) from exc

assert len(users) > 0
assert users[0].name == "Ada"

With basst

from basst import Client

api = Client("https://api.example.com")

users = (
    api.get("/users")
    .query(page=1, limit=10)
    .expect()
    .status(200)
    .model_list(User)
)

assert len(users) > 0
assert users[0].name == "Ada"

What basst gives you

  • Less boilerplate -- 4-8 lines per test instead of 15-20. No manual status checks, JSON parsing, try/except blocks, or hand-rolled error messages.
  • Readable chains -- api.post("/users").json(payload).expect().status(201).model(User) reads like a spec.
  • One-step model binding -- .model(User) returns a real Pydantic instance with full type information. No intermediate dict spreading.
  • Automatic content-type validation -- Catches HTML error pages before JSON parsing, with clear error messages.
  • Contextual errors -- Every error includes HTTP method, URL, and response body. No more assert 404 == 200 mysteries in CI logs.
  • Pluggable matchers -- Five built-ins (equals, contains, starts_with, ends_with, matches) plus any Callable[[str], None].
  • Async without complexity -- await only at .expect(). Everything after is synchronous.
  • Reusable client config -- Set base URL, headers, auth, timeout once. Override per-request.
  • Escape hatches -- .raw gives you the underlying httpx.Response when you need it.

When to use basst

basst is for integration and API tests -- the kind you write in pytest to verify REST endpoints return the right status codes, headers, and response bodies:

  • Testing your own API endpoints during development
  • Contract testing against third-party APIs
  • Smoke tests in CI/CD pipelines
  • Any test where you fetch JSON and validate it against a Pydantic model

basst is not a replacement for httpx. It wraps httpx for a testing-focused API. For runtime HTTP requests, use httpx directly.

Inspiration

basst draws inspiration from REST Assured (Java) and gavv/httpexpect (Go), adapted for Python's type system and Pydantic ecosystem.