Skip to content

Testing

VelociPy ships with TestClient and AsyncTestClient so you can hunt bugs without starting a server. Both clients drive a VelociPy app directly and support the same ASGI and RSGI interfaces you run in production.

No extra framework dependencies are required. The clients work with any test runner that supports synchronous or asynchronous tests, such as pytest with pytest-asyncio.

Run the examples

See examples/testing.py for a runnable demo of sync, async, ASGI/RSGI, header, cookie, QUERY, and manual-cleanup tests.


Synchronous tests

Use TestClient as a context manager from ordinary synchronous test code:

from velocipy import VelociPy
from velocipy.status import HTTP_200_OK
from velocipy.testing import TestClient

app = VelociPy()


@app.get("/")
def index() -> dict[str, str]:
    return {"hello": "world"}


def test_index() -> None:
    with TestClient(app) as client:
        response = client.get("/")
        assert response.status_code == HTTP_200_OK
        assert response.json() == {"hello": "world"}

The context manager enters the app's lifespan if one is configured, so startup and shutdown hooks run around the test.

Asynchronous tests

For async test suites, use AsyncTestClient with async with:

import pytest

from velocipy import VelociPy
from velocipy.status import HTTP_200_OK
from velocipy.testing import AsyncTestClient

app = VelociPy()


@app.get("/")
def index() -> dict[str, str]:
    return {"hello": "world"}


@pytest.mark.asyncio
async def test_index_async() -> None:
    async with AsyncTestClient(app) as client:
        response = await client.get("/")
        assert response.status_code == HTTP_200_OK
        assert response.json() == {"hello": "world"}

Testing both interfaces

VelociPy runs on ASGI (Uvicorn, Hypercorn) and RSGI (Granian). Test both interfaces with pytest.mark.parametrize:

import pytest

from velocipy import VelociPy
from velocipy.constants import Interface
from velocipy.status import HTTP_200_OK
from velocipy.testing import TestClient

app = VelociPy()


@app.get("/")
def index() -> dict[str, str]:
    return {"hello": "world"}


@pytest.mark.parametrize("interface", [Interface.ASGI, Interface.RSGI])
def test_index_on_both_interfaces(interface: Interface) -> None:
    with TestClient(app, interface=interface) as client:
        response = client.get("/")
        assert response.status_code == HTTP_200_OK
        assert response.json() == {"hello": "world"}

RSGI is the fastest production path; ASGI offers the broadest compatibility with existing async middleware and tools. Testing both catches interface-specific surprises early.

Headers and cookies

The clients accept the same arguments as httpx. Pass headers directly and pass cookies through the client constructor:

from typing import Annotated

from velocipy import Cookie, Header, VelociPy
from velocipy.status import HTTP_200_OK
from velocipy.testing import TestClient

app = VelociPy()


@app.get("/headers")
def headers(x_token: Annotated[str, Header(...)]) -> dict[str, str]:
    return {"token": x_token}


@app.get("/cookies")
def cookies(session_id: Annotated[str, Cookie(...)]) -> dict[str, str]:
    return {"session_id": session_id}


def test_headers() -> None:
    with TestClient(app) as client:
        response = client.get("/headers", headers={"x-token": "secret"})
        assert response.status_code == HTTP_200_OK
        assert response.json() == {"token": "secret"}


def test_cookies() -> None:
    with TestClient(app, cookies={"session_id": "abc123"}) as client:
        response = client.get("/cookies")
        assert response.status_code == HTTP_200_OK
        assert response.json() == {"session_id": "abc123"}

The QUERY method

VelociPy supports the HTTP QUERY method. Use client.query(...) to test QUERY endpoints:

from typing import Any

from velocipy import VelociPy
from velocipy.status import HTTP_200_OK
from velocipy.testing import TestClient

app = VelociPy()


@app.query("/search")
def do_search(query: dict[str, Any]) -> dict[str, Any]:
    return {"q": query.get("q"), "limit": query.get("limit", 10)}


def test_query_method() -> None:
    with TestClient(app) as client:
        response = client.query(
            "/search",
            json={"q": "velocipy", "limit": 5},
        )
        assert response.status_code == HTTP_200_OK
        assert response.json() == {"q": "velocipy", "limit": 5}

Lifespan events

If the app defines a lifespan context manager, the test clients enter it when the context opens and exit it on close. This lets you test startup resources such as database pools or configuration loading:

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any

from velocipy import VelociPy
from velocipy.testing import TestClient


@asynccontextmanager
async def lifespan(app: VelociPy) -> AsyncIterator[None]:
    app.state.started = True
    yield
    app.state.started = False


app = VelociPy(lifespan=lifespan)


@app.get("/")
def index() -> dict[str, bool]:
    return {"started": app.state.started}


def test_lifespan() -> None:
    with TestClient(app) as client:
        response = client.get("/")
        assert response.json() == {"started": True}

Manual cleanup

When a context manager is inconvenient, create the client directly and close it explicitly:

from velocipy.testing import TestClient

client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
client.close()

For async clients, await aclose():

async def test_with_manual_cleanup() -> None:
    client = AsyncTestClient(app)
    response = await client.get("/")
    assert response.status_code == 200
    await client.aclose()

Dependency overrides

Replace dependencies during tests with app.dependency_overrides:

from typing import Annotated, Any

from velocipy import Depends, VelociPy
from velocipy.testing import TestClient

app = VelociPy()


def real_db() -> dict[str, Any]:
    return {"source": "production"}


@app.get("/items")
async def list_items(db: Annotated[dict[str, Any], Depends(real_db)]):
    return db


def test_with_override() -> None:
    app.dependency_overrides[real_db] = lambda: {"source": "test"}
    try:
        client = TestClient(app)
        response = client.get("/items")
        assert response.json() == {"source": "test"}
    finally:
        app.dependency_overrides.clear()

See Dependencies for more on dependency injection and overrides.

Custom base URL

The default base URL is http://testserver. Override it when your handlers inspect the request URL:

with TestClient(app, base_url="https://api.example.com") as client:
    response = client.get("/")

Testing uploads, WebSockets, and background tasks

Feature-specific testing patterns are covered on their own pages:

  • Uploads — multipart file uploads with files={...}.
  • Background tasks — mocking task functions because they run after the response.
  • WebSockets — WebSocket endpoint testing.

At a glance

Pattern How
Sync test with TestClient(app) as client:
Async test async with AsyncTestClient(app) as client:
ASGI/RSGI TestClient(app, interface=Interface.RSGI)
Headers client.get("/", headers={...})
Cookies Pass cookies={...} to the client constructor
QUERY client.query("/search", json={...})
Lifespan Entered by the context manager automatically
Cleanup client.close() / await client.aclose()
Override app.dependency_overrides[func] = replacement
Base URL TestClient(app, base_url="...")
Example What it shows
examples/testing.py Sync, async, ASGI/RSGI, header, cookie, QUERY, and manual-cleanup tests.