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 == 200mysteries in CI logs. - Pluggable matchers -- Five built-ins (
equals,contains,starts_with,ends_with,matches) plus anyCallable[[str], None]. - Async without complexity --
awaitonly at.expect(). Everything after is synchronous. - Reusable client config -- Set base URL, headers, auth, timeout once. Override per-request.
- Escape hatches --
.rawgives you the underlyinghttpx.Responsewhen 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.