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