# How to Test Air Applications with pytest: A Complete Guide

> Learn to test Air applications effectively with pytest. This guide shows how to leverage FastAPI's TestClient for robust testing of your Air apps.

- Repository: [Feldroy/air](https://github.com/feldroy/air)
- Tags: how-to-guide
- Published: 2026-03-01

---

**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`](https://github.com/feldroy/air/blob/main/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`](https://github.com/feldroy/air/blob/main/tests/test_applications.py) validates every technique described below.

## Understanding the Testing Architecture

### ASGI Compatibility and FastAPI Internals

In [`src/air/applications.py`](https://github.com/feldroy/air/blob/main/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`](https://github.com/feldroy/air/blob/main/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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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:

```bash
pip install -e .[test]
pytest -q

```

The framework's own tests in [`tests/test_applications.py`](https://github.com/feldroy/air/blob/main/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`](https://github.com/feldroy/air/blob/main/src/air/applications.py) forwards requests to FastAPI, enabling standard `TestClient` usage.
- **HTML-First Testing**: Default responses use `AirResponse` from [`src/air/responses.py`](https://github.com/feldroy/air/blob/main/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`](https://github.com/feldroy/air/blob/main/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.