From 96dec57bfac9dc085a6aef6490be07de97b50457 Mon Sep 17 00:00:00 2001 From: Jonas Lundberg Date: Fri, 10 Apr 2026 11:37:13 +0200 Subject: [PATCH 1/2] Allow setting default mock response --- CHANGELOG.md | 4 ++-- respx/models.py | 5 +++-- respx/router.py | 43 ++++++++++++++++++++++++++++++++++--------- respx/types.py | 9 ++++++++- tests/test_router.py | 43 +++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 88 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfe5816..80ae883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed -- Fix regression causing `params` pattern to stop working under some conditions, - by doing a strict detection of `ANY` in multi items patterns (#313) +- Fix regression causing `params` pattern to stop working under some conditions, by + doing a strict detection of `ANY` in multi items patterns (#313) ### CI diff --git a/respx/models.py b/respx/models.py index 7585171..78e5406 100644 --- a/respx/models.py +++ b/respx/models.py @@ -20,6 +20,7 @@ from .patterns import M, Pattern from .types import ( + DEFAULT, CallableSideEffect, Content, CookieTypes, @@ -397,8 +398,8 @@ def resolve(self, request: httpx.Request, **kwargs: Any) -> RouteResultTypes: result = self._return_value else: - # Auto mock a new response - result = httpx.Response(200, request=request) + # Auto mock a router default response + return DEFAULT if isinstance(result, httpx.Response) and not result._request: # Clone reused Response for immutability diff --git a/respx/router.py b/respx/router.py index 449a5a4..4ad384a 100644 --- a/respx/router.py +++ b/respx/router.py @@ -8,7 +8,6 @@ Dict, Generator, List, - NewType, Optional, Tuple, Type, @@ -30,10 +29,15 @@ SideEffectError, ) from .patterns import Pattern, merge_patterns, parse_url_patterns -from .types import DefaultType, ResolvedResponseTypes, RouteResultTypes, URLPatternTypes - -Default = NewType("Default", object) -DEFAULT = Default(...) +from .types import ( + DEFAULT, + Default, + DefaultType, + ResolvedResponseTypes, + RouteResultTypes, + SideEffectListTypes, + URLPatternTypes, +) class Router: @@ -43,11 +47,19 @@ def __init__( assert_all_called: bool = True, assert_all_mocked: bool = True, base_url: Optional[str] = None, + default: Optional[SideEffectListTypes] = None, ) -> None: self._assert_all_called = assert_all_called self._assert_all_mocked = assert_all_mocked self._bases = parse_url_patterns(base_url, exact=False) + if default is None: + self._default_route = Route().respond(200) + elif isinstance(default, httpx.Response): + self._default_route = Route().mock(return_value=default) + else: + self._default_route = Route().mock(side_effect=default) + self.routes = RouteList() self.calls = CallList() @@ -237,6 +249,11 @@ def record( if route: route.calls.append(call) + def default_response(self, request: httpx.Request) -> httpx.Response: + response = self._default_route.resolve(request) + assert isinstance(response, httpx.Response), "Unsupported default mock" + return response + @contextmanager def resolver(self, request: httpx.Request) -> Generator[ResolvedRoute, None, None]: resolved = ResolvedRoute() @@ -249,8 +266,12 @@ def resolver(self, request: httpx.Request) -> Generator[ResolvedRoute, None, Non if self._assert_all_mocked: raise AllMockedAssertionError(f"RESPX: {request!r} not mocked!") - # Auto mock a successful empty response - resolved.response = httpx.Response(200) + # No route found .. Auto mock default + resolved.response = self.default_response(request) + + elif resolved.response is DEFAULT: + # Route has no mocked response or side effect .. Auto mock default + resolved.response = self.default_response(request) elif resolved.response == request: # Pass-through request @@ -282,7 +303,9 @@ def resolve(self, request: httpx.Request) -> ResolvedRoute: resolved.response = cast(ResolvedResponseTypes, prospect) break - if resolved.response and isinstance(resolved.response.stream, httpx.ByteStream): + if isinstance(resolved.response, httpx.Response) and isinstance( + resolved.response.stream, httpx.ByteStream + ): resolved.response.read() # Pre-read stream return resolved @@ -304,7 +327,9 @@ async def aresolve(self, request: httpx.Request) -> ResolvedRoute: resolved.response = cast(ResolvedResponseTypes, prospect) break - if resolved.response and isinstance(resolved.response.stream, httpx.ByteStream): + if isinstance(resolved.response, httpx.Response) and isinstance( + resolved.response.stream, httpx.ByteStream + ): await resolved.response.aread() # Pre-read stream return resolved diff --git a/respx/types.py b/respx/types.py index 1cf3e0c..3475478 100644 --- a/respx/types.py +++ b/respx/types.py @@ -40,12 +40,19 @@ DefaultType = TypeVar("DefaultType", bound=Any) + +class Default: + ... + + +DEFAULT = Default() + URLPatternTypes = Union[str, Pattern[str], URL, httpx.URL] QueryParamTypes = Union[ bytes, str, List[Tuple[str, Any]], Dict[str, Any], Tuple[Tuple[str, Any], ...] ] -ResolvedResponseTypes = Optional[Union[httpx.Request, httpx.Response]] +ResolvedResponseTypes = Optional[Union[httpx.Request, httpx.Response, Default]] RouteResultTypes = Union[ResolvedResponseTypes, Awaitable[ResolvedResponseTypes]] CallableSideEffect = Callable[..., RouteResultTypes] SideEffectListTypes = Union[httpx.Response, Exception, Type[Exception]] diff --git a/tests/test_router.py b/tests/test_router.py index e820407..35f53d5 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -5,7 +5,7 @@ import pytest from respx import Route, Router -from respx.models import AllMockedAssertionError, PassThrough, RouteList +from respx.models import AllMockedAssertionError, MockResponse, PassThrough, RouteList from respx.patterns import Host, M, Method @@ -20,7 +20,7 @@ async def test_empty_router(): await router.aresolve(request) -async def test_empty_router__auto_mocked(): +async def test_empty_router__auto_mock_success(): router = Router(assert_all_mocked=False) request = httpx.Request("GET", "https://example.org/") @@ -37,6 +37,45 @@ async def test_empty_router__auto_mocked(): assert resolved.response.status_code == 200 +async def test_empty_router__auto_mock_default_response(): + router = Router(assert_all_mocked=False, default=MockResponse(404)) + + request = httpx.Request("GET", "https://example.org/") + resolved = router.resolve(request) + + assert resolved.route is None + assert isinstance(resolved.response, httpx.Response) + assert resolved.response.status_code == 404 + + resolved = await router.aresolve(request) + + assert resolved.route is None + assert isinstance(resolved.response, httpx.Response) + assert resolved.response.status_code == 404 + + +async def test_empty_router__auto_mock_default_exception(): + router = Router(assert_all_mocked=False, default=httpx.NetworkError) + + request = httpx.Request("GET", "https://example.org/") + with pytest.raises(httpx.NetworkError): + router.resolve(request) + + with pytest.raises(httpx.NetworkError): + await router.aresolve(request) + + +async def test_empty_router__raises_with_default_callable_side_effect(): + router = Router( + assert_all_mocked=False, + default=lambda r: r, # type: ignore[arg-type] + ) + + request = httpx.Request("GET", "https://example.org/") + with pytest.raises(AssertionError, match="Unsupported default mock"): + router.resolve(request) + + @pytest.mark.parametrize( ("args", "kwargs", "expected"), [ From 10a23c87cd25af6d5e316d07275c1c9b1b7e40d7 Mon Sep 17 00:00:00 2001 From: Jonas Lundberg Date: Fri, 10 Apr 2026 12:02:46 +0200 Subject: [PATCH 2/2] Add default to decorator/ctx manager --- respx/router.py | 8 ++++++++ tests/test_mock.py | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/respx/router.py b/respx/router.py index 4ad384a..05dc78d 100644 --- a/respx/router.py +++ b/respx/router.py @@ -352,12 +352,14 @@ def __init__( assert_all_called: bool = True, assert_all_mocked: bool = True, base_url: Optional[str] = None, + default: Optional[SideEffectListTypes] = None, using: Optional[Union[str, Default]] = DEFAULT, ) -> None: super().__init__( assert_all_called=assert_all_called, assert_all_mocked=assert_all_mocked, base_url=base_url, + default=default, ) self.Mocker: Optional[Type[Mocker]] = None self._using = using @@ -370,6 +372,7 @@ def __call__( assert_all_called: Optional[bool] = None, assert_all_mocked: Optional[bool] = None, base_url: Optional[str] = None, + default: Optional[SideEffectListTypes] = None, using: Optional[Union[str, Default]] = DEFAULT, ) -> "MockRouter": ... # pragma: nocover @@ -382,6 +385,7 @@ def __call__( assert_all_called: Optional[bool] = None, assert_all_mocked: Optional[bool] = None, base_url: Optional[str] = None, + default: Optional[SideEffectListTypes] = None, using: Optional[Union[str, Default]] = DEFAULT, ) -> Callable: ... # pragma: nocover @@ -393,6 +397,7 @@ def __call__( assert_all_called: Optional[bool] = None, assert_all_mocked: Optional[bool] = None, base_url: Optional[str] = None, + default: Optional[SideEffectListTypes] = None, using: Optional[Union[str, Default]] = DEFAULT, ) -> Union["MockRouter", Callable]: """ @@ -414,6 +419,9 @@ def __call__( settings["assert_all_called"] = assert_all_called if assert_all_mocked is not None: settings["assert_all_mocked"] = assert_all_mocked + if default is not None: + settings["default"] = default + settings.setdefault("assert_all_mocked", False) respx_mock = self.__class__(**settings) return respx_mock diff --git a/tests/test_mock.py b/tests/test_mock.py index 12742f8..45cc5b9 100644 --- a/tests/test_mock.py +++ b/tests/test_mock.py @@ -327,6 +327,13 @@ async def test_configured_router_reuse(client): assert respx.calls.call_count == 0 +def test_configured_router_with_default_mock(): + router = respx.mock(default=httpx.Response(404)) + with router: + response = httpx.get("https://example.com/") + assert response.status_code == 404 + + async def test_router_return_type_misuse(): router = respx.mock(assert_all_called=False) route = router.get("https://hot.dog/")