Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,29 @@ The `make test` and `make pytest` commands automatically run tests from a tempor
- `make fix` — Auto-format code with `ruff format` and `cargo fmt`.
- `make develop` — Build the Rust extension into `.venv` without running tests.

## Writing Application Tests

OxyRoute ships with an integrated `TestClient` for writing synchronous HTTP tests against your application without needing to start a real server.

```python
from oxyroute import App
from oxyroute.testing import TestClient

app = App()

@app.get("/")
def home():
return {"status": "ok"}

def test_home():
with TestClient(app) as client:
resp = client.get("/")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
```

Using `with TestClient(app)` ensures that the application's `__rsgi_init__` and `__rsgi_del__` lifespan hooks are run synchronously.

## Granian RSGI (end-to-end)

`tests/test_granian_e2e.py` starts a real **Granian** subprocess with `--interface rsgi`, sends HTTP requests with **httpx**, then stops the server. It is part of the normal **pytest** run when `granian` is installed (`oxyroute[dev]` includes it). The same file runs in **CI** on every matrix combination (Linux, macOS, Windows), so the native RSGI path is exercised against a real server, not only the in-process httpx test transport.
Expand Down
61 changes: 61 additions & 0 deletions tests/_rsgi_test_transport.py → oxyroute/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from collections.abc import Callable
from typing import Any

import httpx

_BLOCKING_LOOP_LOCAL = threading.local()


Expand Down Expand Up @@ -409,3 +411,62 @@ async def asgi3(scope: dict[str, Any], receive: Any, send: Any) -> None:

asgi_test_app = build_test_app
"""Alias: ``asgi_test_app(app)`` returns an ASGI3 callable for httpx.ASGITransport."""


class TestClient(httpx.Client):
"""Synchronous test client for OxyRoute apps.

Wraps the RSGI testing transport and an async httpx client in a background
thread so it can be used in fully synchronous tests.
"""

def __init__(self, app: Any, base_url: str = "http://testserver", **kwargs: Any) -> None:
self.app = app
self._loop = asyncio.new_event_loop()
self._thread = threading.Thread(target=self._run_loop, daemon=True)
self._thread.start()

transport = httpx.ASGITransport(app=asgi_test_app(app), client=("127.0.0.1", 12345))
self.async_client = httpx.AsyncClient(transport=transport, base_url=base_url, **kwargs)

super().__init__(
transport=httpx.MockTransport(lambda r: httpx.Response(200)),
base_url=base_url,
**kwargs,
)

def _run_loop(self) -> None:
asyncio.set_event_loop(self._loop)
self._loop.run_forever()

def _run_sync(self, coro: Any) -> Any:
return asyncio.run_coroutine_threadsafe(coro, self._loop).result()

def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
resp = self._run_sync(self.async_client.send(request, **kwargs))
self._run_sync(resp.aread())
return resp

def close(self) -> None:
self._run_sync(self.async_client.aclose())
if self._loop.is_running():
self._loop.call_soon_threadsafe(self._loop.stop)
self._thread.join()
super().close()

def __enter__(self) -> TestClient:
self._run_sync(self.async_client.__aenter__())
if hasattr(self.app, "__rsgi_init__"):
init = self.app.__rsgi_init__()
if asyncio.iscoroutine(init):
self._run_sync(init)
return self

def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
self._run_sync(self.async_client.__aexit__(exc_type, exc_value, traceback))
if hasattr(self.app, "__rsgi_del__"):
dele = self.app.__rsgi_del__()
if asyncio.iscoroutine(dele):
self._run_sync(dele)
self.close()
super().__exit__(exc_type, exc_value, traceback)
2 changes: 1 addition & 1 deletion tests/test_405.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import httpx
from oxyroute import App
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def test_405_get_on_post_only_path() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_api_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import pytest
from oxyroute import APIRouter, App
from oxyroute.router import join_path
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def test_join_path() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_cors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import httpx
from oxyroute import App, CORSConfig, apply_cors
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def test_cors_preflight_204_allows_post() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_cors_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import httpx
from oxyroute import App
from oxyroute.cors import CORSConfig, apply_cors
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


@dataclass
Expand Down
2 changes: 1 addition & 1 deletion tests/test_csrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import httpx
from oxyroute import App
from oxyroute.csrf import CSRFConfig, apply_csrf
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app

_HDR = "X-CSRF-Token"
CK = "oxyroute_csrf"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_db_query.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import httpx
import pytest
from oxyroute import App, DBQuery, Depends
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


@pytest.mark.anyio
Expand Down
2 changes: 1 addition & 1 deletion tests/test_dep_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import httpx
from oxyroute import App
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def test_dep_second_receives_first_by_name() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_exception_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import httpx
from oxyroute import App, Response
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def test_exception_handlers():
Expand Down
4 changes: 2 additions & 2 deletions tests/test_form_body.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import httpx
from oxyroute import App
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def test_urlencoded_form_fields() -> None:
Expand Down Expand Up @@ -67,7 +67,7 @@ def test_payload_too_large_413() -> None:

import httpx
from oxyroute import App
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app

os.environ["OXYROUTE_MAX_BODY_BYTES"] = "20"

Expand Down
4 changes: 2 additions & 2 deletions tests/test_handler_errors_500.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import httpx
from oxyroute import App
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def _make_app() -> App:
Expand Down Expand Up @@ -59,7 +59,7 @@ def test_500_includes_detail_when_debug_env() -> None:

import httpx
from oxyroute import App
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app

os.environ["OXYROUTE_DEBUG"] = "1"

Expand Down
2 changes: 1 addition & 1 deletion tests/test_head_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import httpx
from oxyroute import App, Response
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def test_asgi_head_same_path_as_get_empty_body() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_header_sanitization.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import httpx
from oxyroute import App, HTTPException, Response
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def test_response_header_crlf_is_rejected_with_500() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_http_exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import httpx
from oxyroute import App, HTTPException
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def test_http_exception_404_string_detail() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_json_body.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import httpx
from oxyroute import App
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def test_json_body_nested_types() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_jwt_cookie.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import httpx
import pytest
from oxyroute import App
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app

oxyjwt = pytest.importorskip("oxyjwt")

Expand Down
2 changes: 1 addition & 1 deletion tests/test_jwt_route_iss_aud.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import httpx
import pytest
from oxyroute import App
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app

oxyjwt = pytest.importorskip("oxyjwt")

Expand Down
2 changes: 1 addition & 1 deletion tests/test_jwt_rs256.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import jwt
import pytest
from oxyroute import App
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app

_FIX = Path(__file__).resolve().parent / "fixtures" / "rsa"
_PUB = (_FIX / "public_pkcs8.pem").read_text()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import httpx
from oxyroute import App, Response
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def test_middleware_cors_preflight_204_no_route_ran() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import httpx
import pytest
from oxyroute import App
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def test_openapi_shows_route() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_query_decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import httpx
from oxyroute import App
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def test_query_value_percent_decoded() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_routing_autocompile.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import httpx
from oxyroute import App
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def test_routes_added_after_first_request_still_resolve() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_security_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import httpx
from oxyroute import App, SecurityHeadersConfig
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def test_security_headers_merged_on_get() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import httpx
from oxyroute import App, send_sse
from tests._rsgi_test_transport import asgi_test_app
from oxyroute.testing import asgi_test_app


def test_sse_response_body_and_content_type() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import httpx
from oxyroute import App
from oxyroute.testing import asgi_test_app
from pydantic import BaseModel
from tests._rsgi_test_transport import asgi_test_app


class UserBody(BaseModel):
Expand Down
Loading