How to Test Air Applications with pytest: A Complete Guide
You test Air applications using FastAPI's TestClient because Air is a thin wrapper around FastAPI that exposes the same ASGI interface and dependency injection system.
Air is a Python framework that simplifies building HTML-first web applications by wrapping FastAPI. Since the Air class in feldroy/air maintains full ASGI compatibility and exposes the underlying FastAPI instance through src/air/applications.py, you can test Air applications with pytest using identical patterns to FastAPI. The repository's own test suite in tests/test_applications.py validates every technique described below.
Understanding the Testing Architecture
ASGI Compatibility and FastAPI Internals
In src/air/applications.py, the Air class implements __call__ to forward all requests to an internal FastAPI instance. This means any ASGI-compatible test client works out of the box, including fastapi.testclient.TestClient.
The class sets router.route_class = AirRoute to add URL-generation helpers while preserving FastAPI routing semantics. It also configures default_response_class=AirResponse to handle HTML rendering automatically, so your tests don't need to manually specify response types.
Key Properties for Testing
The Air instance exposes dependency_overrides for mocking dependencies during isolated unit tests. When you need to access OpenAPI customization or other FastAPI-specific features, use the fastapi_app property defined in applications.py lines 28-31.
Writing Your First pytest Test
The canonical pattern involves instantiating Air, registering routes with decorators like @app.get() or @app.page(), wrapping the app in TestClient, and asserting on the HTML response.
import air
from fastapi.testclient import TestClient
def test_home_page():
app = air.Air()
@app.get("/")
def index() -> air.H1:
return air.H1("Hello, Air!")
client = TestClient(app)
resp = client.get("/")
assert resp.status_code == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
assert resp.text == "<h1>Hello, Air!</h1>"
This mirrors the test_air_app_factory pattern found in the repository's test suite, where the framework validates basic route registration and HTML output.
Testing Common Patterns
Testing Query Parameters and URL Helpers
Air routes support FastAPI's dependency injection for query parameters. The test suite validates this behavior in test_page_decorator around lines 70-88.
def test_search_route():
app = air.Air()
@app.get("/search")
def search(q: str, page: int = 1) -> air.H1:
return air.H1(f"Search: {q} page {page}")
client = TestClient(app)
resp = client.get("/search?q=air&page=3")
assert resp.status_code == 200
assert resp.text == "<h1>Search: air page 3</h1>"
Overriding Dependencies for Isolated Unit Tests
For mocking databases or external services, use the dependency_overrides mapping on the Air object. This pattern is demonstrated in test_app_dependency_overrides at lines 63-84.
from fastapi import Depends
def test_dependency_override():
app = air.Air()
def get_db() -> str:
return "real_db"
@app.get("/db")
def db_endpoint(db: str = Depends(get_db)) -> air.H1:
return air.H1(f"DB: {db}")
client = TestClient(app)
assert client.get("/db").text == "<h1>DB: real_db</h1>"
# Override in test
def mock_db() -> str:
return "mock_db"
app.dependency_overrides[get_db] = mock_db
assert client.get("/db").text == "<h1>DB: mock_db</h1>"
Testing Custom Exception Handlers
Verify that custom 404 or 500 handlers render correctly without being overwritten by framework defaults. The repository tests this in test_custom_exception_handlers_not_overwritten_by_defaults at lines 88-115.
def test_custom_404_handler():
app = air.Air()
@app.exception_handler(404)
async def custom_404(request, exc):
return air.AirResponse(air.H1("Custom 404"), status_code=404)
client = TestClient(app, raise_server_exceptions=False)
resp = client.get("/nonexistent")
assert resp.status_code == 404
assert resp.text == "<h1>Custom 404</h1>"
Verifying Sync vs Async Execution
Air executes sync handlers in a thread pool and async handlers on the event loop. The repository validates this distinction in test_sync_endpoint_not_on_event_loop at lines 13-44.
import asyncio
def test_sync_vs_async_loop():
app = air.Air()
flags = {}
@app.get("/sync")
def sync_route():
try:
asyncio.get_running_loop()
flags["sync"] = True
except RuntimeError:
flags["sync"] = False
return air.H1("Sync")
@app.get("/async")
async def async_route():
flags["async"] = asyncio.get_running_loop() is not None
return air.H1("Async")
client = TestClient(app)
client.get("/sync")
client.get("/async")
assert flags["sync"] is False # ran in thread pool
assert flags["async"] is True # ran on event loop
Running the Test Suite
Install the package with test dependencies and execute pytest from the repository root:
pip install -e .[test]
pytest -q
The framework's own tests in tests/test_applications.py serve as both verification and living documentation for these testing patterns.
Summary
- ASGI Compatibility: Air's
__call__method insrc/air/applications.pyforwards requests to FastAPI, enabling standardTestClientusage. - HTML-First Testing: Default responses use
AirResponsefromsrc/air/responses.py, automatically settingcontent-typetotext/html; charset=utf-8. - Dependency Injection: Override dependencies using
app.dependency_overridesfor isolated unit tests, identical to FastAPI's pattern. - Execution Models: Sync routes run in thread pools; async routes run on the event loop—both are testable via
TestClient. - Source Reference: The test suite in
tests/test_applications.pyprovides working examples for all patterns including exception handling and route customization.
Frequently Asked Questions
Do I need special plugins to test Air applications with pytest?
No. Because Air wraps FastAPI, you only need fastapi.testclient.TestClient and standard pytest. Install the package with pip install -e .[test] to get all required dependencies including pytest and the FastAPI test client.
How do I test HTML output from Air endpoints?
Assert against resp.text and verify the content-type header equals text/html; charset=utf-8. Air's AirResponse class automatically renders HTML tags like air.H1 into strings, so your tests should check both the status code and the rendered HTML content.
Can I override database connections for testing?
Yes. Use the dependency_overrides dictionary on your Air instance to swap real dependencies for mocks. This works identically to FastAPI: app.dependency_overrides[get_db] = mock_db. The repository demonstrates this in test_app_dependency_overrides.
How do I test error pages like 404s?
Register custom exception handlers using @app.exception_handler(404) and test them with TestClient(app, raise_server_exceptions=False). This prevents the client from raising exceptions internally and allows you to assert on the custom HTML error response body and status code.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →