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 in src/air/applications.py forwards requests to FastAPI, enabling standard TestClient usage.
  • HTML-First Testing: Default responses use AirResponse from src/air/responses.py, automatically setting content-type to text/html; charset=utf-8.
  • Dependency Injection: Override dependencies using app.dependency_overrides for 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.py provides 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →