Testing ¶
Learn how to test your Stario applications using Starlette's test client and best practices for testing handlers, dependencies, and exception handlers.
Overview ¶
Testing Stario applications involves:
- Unit tests for dependencies and business logic
- Integration tests for handlers and their dependencies
- Exception tests for error handling
- End-to-end tests for complete workflows
Stario uses Starlette's TestClient which provides a synchronous interface to test your async application without running a server.
Basic Test Client Setup ¶
The test client allows you to make requests to your app as if it were running on a server:
⚠️ Prerequisite: TestClient requires httpx to be installed. Install it with:
pip install httpx
from starlette.testclient import TestClient
from stario import Stario
from stario.html import div, h1
app = Stario()
@app.query("/")
async def hello():
return div(h1("Hello, World!"))
# Create a test client
with TestClient(app) as client:
# Make requests like you would with requests library
response = client.get("/")
assert response.status_code == 200
assert "Hello, World!" in response.text
Testing Handlers ¶
Simple Handler Test ¶
from stario import Stario
from stario.html import div, h1
from starlette.testclient import TestClient
app = Stario()
@app.query("/")
async def hello():
return div(h1("Hello, World!"))
def test_hello():
with TestClient(app) as client:
response = client.get("/")
assert response.status_code == 200
assert "Hello, World!" in response.text
Handler with Query Parameters ¶
from stario import Stario
from stario.requests import QueryParam
from stario.html import div, p
from starlette.testclient import TestClient
app = Stario()
@app.query("/greet")
async def greet(name: QueryParam[str] = "World"):
return div(p(f"Hello, {name}!"))
def test_greet():
with TestClient(app) as client:
# Test with parameter
response = client.get("/greet?name=Alice")
assert response.status_code == 200
assert "Hello, Alice!" in response.text
# Test without parameter (uses default)
response = client.get("/greet")
assert response.status_code == 200
assert "Hello, World!" in response.text
Handler with Path Parameters ¶
from stario import Stario
from stario.requests import PathParam
from stario.html import div, p
from starlette.testclient import TestClient
app = Stario()
@app.query("/users/{user_id}")
async def user_profile(user_id: PathParam[int]):
return div(p(f"User ID: {user_id}"))
def test_user_profile():
with TestClient(app) as client:
response = client.get("/users/42")
assert response.status_code == 200
assert "User ID: 42" in response.text
Handler with Request Body ¶
from pydantic import BaseModel
from stario import Stario
from stario.requests import JsonBody
from stario.html import div, p
from starlette.testclient import TestClient
app = Stario()
class UserData(BaseModel):
name: str
email: str
@app.command("/users")
async def create_user(user: JsonBody[UserData]):
return div(p(f"Created: {user.name}"))
def test_create_user():
with TestClient(app) as client:
response = client.post(
"/users",
json={"name": "Alice", "email": "alice@example.com"}
)
assert response.status_code == 200
assert "Created: Alice" in response.text
Testing Dependencies ¶
Mocking Dependencies with app.mocks() ¶
Stario provides a mocks() context manager on the app instance for temporarily overriding dependencies during testing. This allows you to inject mock implementations without modifying your handler code.
from typing import Annotated
from starlette.testclient import TestClient
from stario import Stario
from stario.html import div, p
app = Stario()
# Define a database interface
class Database:
async def get_user(self, user_id: int):
raise NotImplementedError
# Real implementation
async def get_real_database() -> Database:
return Database() # Would connect to real DB
# Mock implementation for testing
class MockDatabase(Database):
async def get_user(self, user_id: int):
return {"id": user_id, "name": f"Mock User {user_id}"}
async def mock_get_database() -> Database:
return MockDatabase()
# Handler that uses the dependency
@app.query("/user/{user_id}")
async def user_profile(db: Annotated[Database, get_real_database]):
user = await db.get_user(1)
return div(p(f"User: {user['name']}"))
# Create app with real dependency
def test_user_profile_with_mock():
# Override the dependency during this test
with app.mocks({get_real_database: mock_get_database}):
with TestClient(app) as client:
response = client.get("/user/1")
assert response.status_code == 200
assert "Mock User 1" in response.text
Key points:
- Use
app.mocks()as a context manager before creating the TestClient - Pass a dictionary mapping real dependency functions to their mocks
- The mocks are active only within the context manager block
- You can also use the Annotated type as a key:
{Annotated[Database, get_real_database]: mock_get_database}
Testing Dependency Chains ¶
from typing import Annotated
from starlette.testclient import TestClient
from stario import Stario
from stario.html import div, p
app = Stario()
# Dependency chain: get_db → get_current_user → handler
class Database:
async def get_user(self, user_id: int):
raise NotImplementedError
class User:
def __init__(self, id: int, name: str):
self.id = id
self.name = name
async def get_database() -> Database:
# Real implementation would connect to a database
db = Database()
return db
async def get_current_user(db: Annotated[Database, get_database]) -> User:
# In real code, this would fetch from request headers or session
user = await db.get_user(1)
return user
@app.query("/protected")
async def protected_handler(user: Annotated[User, get_current_user]):
return div(p(f"Hello, {user.name}"))
# For testing, mock the entire dependency chain at the source
class MockDatabase(Database):
async def get_user(self, user_id: int):
return User(user_id, f"Test User {user_id}")
async def mock_get_database() -> Database:
return MockDatabase()
def test_dependency_chain_with_mock():
# Mock the database - the rest of the chain uses the mock automatically
with app.mocks({get_database: mock_get_database}):
with TestClient(app) as client:
response = client.get("/protected")
assert response.status_code == 200
assert "Hello, Test User 1" in response.text
# Alternative: Mock at different levels
def test_mock_at_user_level():
async def mock_get_current_user(db: Annotated[Database, get_database]) -> User:
# Mock the user directly
return User(42, "Mocked Admin")
# Mock get_current_user instead of get_database
with app.mocks({get_current_user: mock_get_current_user}):
with TestClient(app) as client:
response = client.get("/protected")
assert response.status_code == 200
assert "Hello, Mocked Admin" in response.text
Testing Context Manager Dependencies ¶
When your dependencies use context managers for resource cleanup, mocks work seamlessly:
from typing import Annotated
from starlette.testclient import TestClient
from stario import Stario
from stario.html import div, p
app = Stario()
class DatabaseConnection:
async def __aenter__(self):
print("Opening database connection")
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
print("Closing database connection")
async def get_user(self, user_id: int):
return {"id": user_id, "name": f"User {user_id}"}
async def get_db_connection() -> DatabaseConnection:
return DatabaseConnection()
@app.query("/user")
async def get_user_handler(db: Annotated[DatabaseConnection, get_db_connection]):
user = await db.get_user(1)
return div(p(f"User: {user['name']}"))
# Mock that also implements the context manager protocol
class MockDatabaseConnection:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
async def get_user(self, user_id: int):
return {"id": user_id, "name": f"MOCK User {user_id}"}
async def mock_get_db_connection() -> MockDatabaseConnection:
return MockDatabaseConnection()
def test_with_context_manager_mock():
with app.mocks({get_db_connection: mock_get_db_connection}):
with TestClient(app) as client:
response = client.get("/user")
assert response.status_code == 200
assert "MOCK User 1" in response.text
# Cleanup happens automatically after the response
Testing with Multiple Mocks ¶
Override multiple dependencies in a single test:
from typing import Annotated
from starlette.testclient import TestClient
from stario import Stario
from stario.html import div, p
app = Stario()
# First dependency: Database
class Database:
async def get_user(self, user_id: int):
raise NotImplementedError
async def get_database() -> Database:
return Database()
# Second dependency: Cache
class Cache:
async def get(self, key: str):
raise NotImplementedError
async def get_cache() -> Cache:
return Cache()
# Handler uses both dependencies
@app.query("/user/{user_id}")
async def user_info(
user_id: int,
db: Annotated[Database, get_database],
cache: Annotated[Cache, get_cache]
):
# Check cache first
cached = await cache.get(f"user:{user_id}")
if cached:
return div(p(cached))
# Fall back to database
user = await db.get_user(user_id)
return div(p(f"User: {user['name']}"))
# Mocks for testing
class MockDatabase(Database):
async def get_user(self, user_id: int):
return {"id": user_id, "name": f"Test User {user_id}"}
class MockCache(Cache):
async def get(self, key: str):
if key == "user:1":
return "Cached User 1"
return None
async def mock_get_database() -> Database:
return MockDatabase()
async def mock_get_cache() -> Cache:
return MockCache()
def test_with_multiple_mocks():
# Override both dependencies at once
with app.mocks({
get_database: mock_get_database,
get_cache: mock_get_cache,
}):
with TestClient(app) as client:
response = client.get("/user/1")
assert response.status_code == 200
# Should use cached value
assert "Cached User 1" in response.text
Testing Exception Handlers ¶
Testing Custom Exception Handlers ¶
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import HTMLResponse
from starlette.testclient import TestClient
from stario import Stario
app = Stario()
async def not_found_handler(request: Request, exc: HTTPException):
return HTMLResponse(
content="<h1>Custom 404 Page</h1>",
status_code=404,
)
@app.query("/resource")
async def missing_resource():
raise HTTPException(status_code=404, detail="Resource not found")
app.add_exception_handler(404, not_found_handler)
def test_custom_404():
with TestClient(app) as client:
response = client.get("/resource")
assert response.status_code == 404
assert "Custom 404 Page" in response.text
Testing Domain Exceptions ¶
from starlette.testclient import TestClient
from stario import Stario
from stario.requests import PathParam, Request
from stario.responses import HTMLResponse
from stario.html import div, p
class UserNotFoundError(Exception):
pass
async def user_not_found_handler(request: Request, exc: UserNotFoundError):
return HTMLResponse(
content="<h1>User Not Found</h1>",
status_code=404,
)
app = Stario()
@app.query("/users/{user_id}")
async def get_user(user_id: PathParam[int]):
if user_id == 999:
raise UserNotFoundError()
return div(p(f"User {user_id}"))
app.add_exception_handler(UserNotFoundError, user_not_found_handler)
def test_domain_exception():
with TestClient(app) as client:
# Test valid user
response = client.get("/users/1")
assert response.status_code == 200
assert "User 1" in response.text
# Test non-existent user
response = client.get("/users/999")
assert response.status_code == 404
assert "User Not Found" in response.text
Testing Headers and Cookies ¶
Testing Header Parameters ¶
from starlette.testclient import TestClient
from stario.exceptions import HTTPException
from stario import Stario
from stario.requests import Header
from stario.html import div, p
app = Stario()
@app.query("/protected")
async def protected(auth: Header[str | None] = None):
if not auth:
raise HTTPException(status_code=401)
return div(p(f"Auth: {auth}"))
def test_header():
with TestClient(app) as client:
# Test without header
response = client.get("/protected")
assert response.status_code == 401
# Test with header
response = client.get(
"/protected",
headers={"Authorization": "Bearer token123"}
)
assert response.status_code == 200
assert "Bearer token123" in response.text
Testing Cookies ¶
from starlette.testclient import TestClient
from stario import Stario
from stario.requests import Request, Cookie
from stario.responses import Response
from stario.html import div, p, render
async def set_cookie():
response = Response(render(div(p("Cookie set"))))
response.set_cookie("user_id", "123")
return response
async def read_cookie(request: Request):
user_id = request.cookies.get("user_id")
return div(p(f"User: {user_id}"))
app = Stario(
Query("/set-cookie", set_cookie),
Query("/read-cookie", read_cookie),
)
def test_cookies():
client = TestClient(app)
# Set cookie
response = client.get("/set-cookie")
assert "user_id" in response.cookies
# Use cookie in next request
response = client.get("/read-cookie")
# Cookies are maintained across requests in test client
assert "User: 123" in response.text
Testing Streaming Responses ¶
Testing Generators ¶
from stario import Stario
from stario.html import div
from starlette.testclient import TestClient
app = Stario()
@app.query("/count")
async def count_to_five():
for i in range(1, 6):
yield div(f"Count: {i}")
def test_streaming():
with TestClient(app) as client:
response = client.get("/count")
assert response.status_code == 200
assert "Count: 1" in response.text
assert "Count: 5" in response.text
Testing Error Handling in Streams ¶
from stario import Stario
from stario.requests import QueryParams
from stario.html import div
from starlette.testclient import TestClient
app = Stario()
@app.query("/process")
async def process_items(items: QueryParams[int]):
for item in items:
if item < 0:
yield div({"class": "error"}, f"Invalid: {item}")
else:
yield div(f"Processed: {item}")
def test_stream_error_handling():
with TestClient(app) as client:
response = client.get("/process?items=1&items=-5&items=3")
assert response.status_code == 200
assert "Processed: 1" in response.text
assert "Invalid: -5" in response.text
assert "Processed: 3" in response.text
Testing JSON Responses ¶
Handler Returning JSON ¶
from typing import TypedDict
from stario import Stario
from starlette.responses import JSONResponse
from starlette.testclient import TestClient
class UserResponse(TypedDict):
id: int
name: str
app = Stario()
@app.query("/api/user")
async def get_user_json():
return JSONResponse({"id": 1, "name": "Alice"})
def test_json_response():
with TestClient(app) as client:
response = client.get("/api/user")
assert response.status_code == 200
assert response.headers["content-type"] == "application/json"
data = response.json()
assert data["id"] == 1
assert data["name"] == "Alice"
Test Client API Reference ¶
Making Requests ¶
# GET request
response = client.get("/path")
# POST request
response = client.post("/path", json={"key": "value"})
# With query parameters
response = client.get("/path?key=value")
# With headers
response = client.get("/path", headers={"Authorization": "Bearer token"})
# With cookies
response = client.get("/path", cookies={"session": "abc123"})
# With data (form submission)
response = client.post("/path", data={"field": "value"})
Response Object ¶
response = client.get("/")
# Status code
response.status_code # e.g., 200
# Response body
response.text # As string
response.content # As bytes
response.json() # Parse as JSON
# Headers
response.headers # dict-like object
response.headers["content-type"]
# Cookies
response.cookies # dict-like object
# Check if successful
response.is_success # True for 2xx status codes
Best Practices ¶
1. Use Fixtures for Common Setup ¶
import pytest
from starlette.testclient import TestClient
@pytest.fixture
def app():
"""Create app instance for testing"""
return Stario()
@pytest.fixture
def client(app):
"""Create test client"""
return TestClient(app)
def test_something(client):
response = client.get("/")
assert response.status_code == 200
