diff --git a/docs/development.md b/docs/development.md index 0b529b8..309ac62 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. diff --git a/tests/_rsgi_test_transport.py b/oxyroute/testing.py similarity index 84% rename from tests/_rsgi_test_transport.py rename to oxyroute/testing.py index 63a1db4..1a0cba1 100644 --- a/tests/_rsgi_test_transport.py +++ b/oxyroute/testing.py @@ -18,6 +18,8 @@ from collections.abc import Callable from typing import Any +import httpx + _BLOCKING_LOOP_LOCAL = threading.local() @@ -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) diff --git a/tests/test_405.py b/tests/test_405.py index a03f4b4..03ae87f 100644 --- a/tests/test_405.py +++ b/tests/test_405.py @@ -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: diff --git a/tests/test_api_router.py b/tests/test_api_router.py index edd660a..b4a3218 100644 --- a/tests/test_api_router.py +++ b/tests/test_api_router.py @@ -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: diff --git a/tests/test_cors.py b/tests/test_cors.py index c7ec48b..3089e6e 100644 --- a/tests/test_cors.py +++ b/tests/test_cors.py @@ -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: diff --git a/tests/test_cors_units.py b/tests/test_cors_units.py index 68748dc..2310b5e 100644 --- a/tests/test_cors_units.py +++ b/tests/test_cors_units.py @@ -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 diff --git a/tests/test_csrf.py b/tests/test_csrf.py index 09d66d0..f637ab4 100644 --- a/tests/test_csrf.py +++ b/tests/test_csrf.py @@ -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" diff --git a/tests/test_db_query.py b/tests/test_db_query.py index d45dee7..e402eb3 100644 --- a/tests/test_db_query.py +++ b/tests/test_db_query.py @@ -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 diff --git a/tests/test_dep_chain.py b/tests/test_dep_chain.py index 208893b..a470002 100644 --- a/tests/test_dep_chain.py +++ b/tests/test_dep_chain.py @@ -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: diff --git a/tests/test_exception_handlers.py b/tests/test_exception_handlers.py index 3ef68d0..69c6ed7 100644 --- a/tests/test_exception_handlers.py +++ b/tests/test_exception_handlers.py @@ -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(): diff --git a/tests/test_form_body.py b/tests/test_form_body.py index 82a8453..0ca99d5 100644 --- a/tests/test_form_body.py +++ b/tests/test_form_body.py @@ -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: @@ -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" diff --git a/tests/test_handler_errors_500.py b/tests/test_handler_errors_500.py index 09ab7b3..a6e5f84 100644 --- a/tests/test_handler_errors_500.py +++ b/tests/test_handler_errors_500.py @@ -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: @@ -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" diff --git a/tests/test_head_options.py b/tests/test_head_options.py index 129a78f..b43bd59 100644 --- a/tests/test_head_options.py +++ b/tests/test_head_options.py @@ -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: diff --git a/tests/test_header_sanitization.py b/tests/test_header_sanitization.py index 6d59ac9..df7e1d4 100644 --- a/tests/test_header_sanitization.py +++ b/tests/test_header_sanitization.py @@ -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: diff --git a/tests/test_http_exception.py b/tests/test_http_exception.py index cae3e25..3a853e6 100644 --- a/tests/test_http_exception.py +++ b/tests/test_http_exception.py @@ -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: diff --git a/tests/test_json_body.py b/tests/test_json_body.py index 139346f..bade8cd 100644 --- a/tests/test_json_body.py +++ b/tests/test_json_body.py @@ -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: diff --git a/tests/test_jwt_cookie.py b/tests/test_jwt_cookie.py index 2148b9b..6f22140 100644 --- a/tests/test_jwt_cookie.py +++ b/tests/test_jwt_cookie.py @@ -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") diff --git a/tests/test_jwt_route_iss_aud.py b/tests/test_jwt_route_iss_aud.py index 2e95545..ff1dd0d 100644 --- a/tests/test_jwt_route_iss_aud.py +++ b/tests/test_jwt_route_iss_aud.py @@ -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") diff --git a/tests/test_jwt_rs256.py b/tests/test_jwt_rs256.py index 3632f5a..54507a4 100644 --- a/tests/test_jwt_rs256.py +++ b/tests/test_jwt_rs256.py @@ -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() diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 5830f5f..94ce8b1 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -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: diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 9f7cffe..8aaf4b2 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -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: diff --git a/tests/test_query_decode.py b/tests/test_query_decode.py index 98bc82c..f9674a7 100644 --- a/tests/test_query_decode.py +++ b/tests/test_query_decode.py @@ -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: diff --git a/tests/test_routing_autocompile.py b/tests/test_routing_autocompile.py index 19cbcc9..97fd67a 100644 --- a/tests/test_routing_autocompile.py +++ b/tests/test_routing_autocompile.py @@ -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: diff --git a/tests/test_security_headers.py b/tests/test_security_headers.py index 49634d3..491d21e 100644 --- a/tests/test_security_headers.py +++ b/tests/test_security_headers.py @@ -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: diff --git a/tests/test_sse.py b/tests/test_sse.py index ce6b44c..97edd9c 100644 --- a/tests/test_sse.py +++ b/tests/test_sse.py @@ -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: diff --git a/tests/test_validation.py b/tests/test_validation.py index edc46c8..0e25d99 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -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):