Skip to content

Commit 6051978

Browse files
Merge pull request #124 from QueryaHub/issue-102-testclient
feat(testing): ship oxyroute.testing.TestClient
2 parents 1e3a66d + 4fd3a8a commit 6051978

26 files changed

Lines changed: 110 additions & 26 deletions

docs/development.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,29 @@ The `make test` and `make pytest` commands automatically run tests from a tempor
1919
- `make fix` — Auto-format code with `ruff format` and `cargo fmt`.
2020
- `make develop` — Build the Rust extension into `.venv` without running tests.
2121

22+
## Writing Application Tests
23+
24+
OxyRoute ships with an integrated `TestClient` for writing synchronous HTTP tests against your application without needing to start a real server.
25+
26+
```python
27+
from oxyroute import App
28+
from oxyroute.testing import TestClient
29+
30+
app = App()
31+
32+
@app.get("/")
33+
def home():
34+
return {"status": "ok"}
35+
36+
def test_home():
37+
with TestClient(app) as client:
38+
resp = client.get("/")
39+
assert resp.status_code == 200
40+
assert resp.json() == {"status": "ok"}
41+
```
42+
43+
Using `with TestClient(app)` ensures that the application's `__rsgi_init__` and `__rsgi_del__` lifespan hooks are run synchronously.
44+
2245
## Granian RSGI (end-to-end)
2346

2447
`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.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from collections.abc import Callable
1919
from typing import Any
2020

21+
import httpx
22+
2123
_BLOCKING_LOOP_LOCAL = threading.local()
2224

2325

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

410412
asgi_test_app = build_test_app
411413
"""Alias: ``asgi_test_app(app)`` returns an ASGI3 callable for httpx.ASGITransport."""
414+
415+
416+
class TestClient(httpx.Client):
417+
"""Synchronous test client for OxyRoute apps.
418+
419+
Wraps the RSGI testing transport and an async httpx client in a background
420+
thread so it can be used in fully synchronous tests.
421+
"""
422+
423+
def __init__(self, app: Any, base_url: str = "http://testserver", **kwargs: Any) -> None:
424+
self.app = app
425+
self._loop = asyncio.new_event_loop()
426+
self._thread = threading.Thread(target=self._run_loop, daemon=True)
427+
self._thread.start()
428+
429+
transport = httpx.ASGITransport(app=asgi_test_app(app), client=("127.0.0.1", 12345))
430+
self.async_client = httpx.AsyncClient(transport=transport, base_url=base_url, **kwargs)
431+
432+
super().__init__(
433+
transport=httpx.MockTransport(lambda r: httpx.Response(200)),
434+
base_url=base_url,
435+
**kwargs,
436+
)
437+
438+
def _run_loop(self) -> None:
439+
asyncio.set_event_loop(self._loop)
440+
self._loop.run_forever()
441+
442+
def _run_sync(self, coro: Any) -> Any:
443+
return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
444+
445+
def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
446+
resp = self._run_sync(self.async_client.send(request, **kwargs))
447+
self._run_sync(resp.aread())
448+
return resp
449+
450+
def close(self) -> None:
451+
self._run_sync(self.async_client.aclose())
452+
if self._loop.is_running():
453+
self._loop.call_soon_threadsafe(self._loop.stop)
454+
self._thread.join()
455+
super().close()
456+
457+
def __enter__(self) -> TestClient:
458+
self._run_sync(self.async_client.__aenter__())
459+
if hasattr(self.app, "__rsgi_init__"):
460+
init = self.app.__rsgi_init__()
461+
if asyncio.iscoroutine(init):
462+
self._run_sync(init)
463+
return self
464+
465+
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
466+
self._run_sync(self.async_client.__aexit__(exc_type, exc_value, traceback))
467+
if hasattr(self.app, "__rsgi_del__"):
468+
dele = self.app.__rsgi_del__()
469+
if asyncio.iscoroutine(dele):
470+
self._run_sync(dele)
471+
self.close()
472+
super().__exit__(exc_type, exc_value, traceback)

tests/test_405.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import httpx
88
from oxyroute import App
9-
from tests._rsgi_test_transport import asgi_test_app
9+
from oxyroute.testing import asgi_test_app
1010

1111

1212
def test_405_get_on_post_only_path() -> None:

tests/test_api_router.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import pytest
1010
from oxyroute import APIRouter, App
1111
from oxyroute.router import join_path
12-
from tests._rsgi_test_transport import asgi_test_app
12+
from oxyroute.testing import asgi_test_app
1313

1414

1515
def test_join_path() -> None:

tests/test_cors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import httpx
88
from oxyroute import App, CORSConfig, apply_cors
9-
from tests._rsgi_test_transport import asgi_test_app
9+
from oxyroute.testing import asgi_test_app
1010

1111

1212
def test_cors_preflight_204_allows_post() -> None:

tests/test_cors_units.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import httpx
99
from oxyroute import App
1010
from oxyroute.cors import CORSConfig, apply_cors
11-
from tests._rsgi_test_transport import asgi_test_app
11+
from oxyroute.testing import asgi_test_app
1212

1313

1414
@dataclass

tests/test_csrf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import httpx
99
from oxyroute import App
1010
from oxyroute.csrf import CSRFConfig, apply_csrf
11-
from tests._rsgi_test_transport import asgi_test_app
11+
from oxyroute.testing import asgi_test_app
1212

1313
_HDR = "X-CSRF-Token"
1414
CK = "oxyroute_csrf"

tests/test_db_query.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import httpx
22
import pytest
33
from oxyroute import App, DBQuery, Depends
4-
from tests._rsgi_test_transport import asgi_test_app
4+
from oxyroute.testing import asgi_test_app
55

66

77
@pytest.mark.anyio

tests/test_dep_chain.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import httpx
88
from oxyroute import App
9-
from tests._rsgi_test_transport import asgi_test_app
9+
from oxyroute.testing import asgi_test_app
1010

1111

1212
def test_dep_second_receives_first_by_name() -> None:

tests/test_exception_handlers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import httpx
44
from oxyroute import App, Response
5-
from tests._rsgi_test_transport import asgi_test_app
5+
from oxyroute.testing import asgi_test_app
66

77

88
def test_exception_handlers():

0 commit comments

Comments
 (0)