Skip to content

Fix TestClient passing broken request objects to endpoints#1680

Open
brianhelba wants to merge 4 commits intovitalik:masterfrom
brianhelba:test-request
Open

Fix TestClient passing broken request objects to endpoints#1680
brianhelba wants to merge 4 commits intovitalik:masterfrom
brianhelba:test-request

Conversation

@brianhelba
Copy link

@brianhelba brianhelba commented Feb 17, 2026

Endpoints called via TestClient received a request where any HttpRequest method not explicitly stubbed returned a Mock object instead of a real value.

This was causing all the following bugs, which are now resolved:

  • request.build_absolute_uri() was replaced with a stub that ignored the request path entirely, returning "http://testlocation/" for every endpoint. Furthermore, this produced incorrect pagination URLs.
  • request.get_host() returned a Mock. It now returns the value "testserver", which is specifically added to ALLOWED_HOSTS by the Django test framework. The use of testserver as a host name now allows the HttpRequest.build_absolute_uri() to work properly without additional mocking.
  • request.get_full_path() returned a Mock instead of the path with the full query string.
  • request.is_secure() returned a (truthy) Mock instead of False.
  • request.body returned a str, but HttpRequest.body returns bytes.
  • request.META was missing required WSGI keys (SERVER_NAME, SERVER_PORT, QUERY_STRING, PATH_INFO), so several parts of Django would raise a KeyError when the request was used.

Endpoints called via `TestClient` received a `request` where any
`HttpRequest` method not explicitly stubbed returned a `Mock` object
instead of a real value.

This was causing all the following bugs, which are now resolved:

`request.build_absolute_uri()` was replaced with a stub that ignored the
request path entirely, returning `"http://testlocation/"` for every
endpoint. Furthermore, this produced incorrect pagination URLs.

`request.get_host()` returned a `Mock`. It now returns the value
`"testserver"`, which is specifically added to `ALLOWED_HOSTS`
by the Django test framework. The use of `testserver` as a host name
now allows the `HttpRequest.build_absolute_uri()` to work
properly without additional mocking.

`request.get_full_path()` returned a `Mock` instead of the path with
the full query string.

`request.is_secure()` returned a (truthy) `Mock` instead of False.

`request.body` returned a `str`, but `HttpRequest.body` returns `bytes`.

`request.META` was missing required WSGI keys (`SERVER_NAME`,
`SERVER_PORT`, `QUERY_STRING`, `PATH_INFO`), so several parts of Django
would raise a `KeyError` when the `request` was used.
@brianhelba
Copy link
Author

@vitalik Please take a look. I believe this is a significant bugfix to Django Ninja's TestClient. It doesn't change any external APIs or behavior, except to fix broken methods to return usable or more correct data in a test context.

def parse_body(self, request: HttpRequest):
"just splitting body to lines"
return request.body.encode().splitlines()
return request.body.splitlines()
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request.body should (per runtime behavior and type annotations) return bytes, which it now does.

Comment on lines 509 to +511
"items": [5, 6, 7, 8, 9],
"next": "http://testlocation/?skip=10",
"prev": "http://testlocation/?skip=0",
"next": "http://testserver/items_9?skip=10",
"prev": "http://testserver/items_9?skip=0",
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Evidence of the previously broken behavior of the request.build_absolute_uri() method, now fixed.

"/request/build_absolute_uri/location",
HTTPStatus.OK,
"http://testlocation/location",
"http://testserver/different-location",
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strengthen this test case, to ensure passing a parameter actually returns a changed URL.



def test_client_path_query_string():
r = client.get("/test-path?foo=bar")
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a separate code pathway for parsing a query string from the path as a string. Previously, the value of request.path was broken if the query string was passed this way, so a separate test case to query_params is warranted here.

# so don't bother
request.path_info = path

request.META = request_params.pop(
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See https://wsgi.readthedocs.io/en/latest/definitions.html

Some of these (like SERVER_NAME) must be set or Django will raise KeyError when calling underlying methods.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes Django Ninja’s TestClient request construction so endpoint code receives a real django.http.HttpRequest with correct host/path/body/META behavior instead of a partially-stubbed Mock, resolving several request-method/attribute inconsistencies that affected pagination URLs and other request-dependent logic.

Changes:

  • Replace mocked HttpRequest construction with a real HttpRequest() and populate body, GET, path/path_info, and WSGI-like META.
  • Update/extend tests to assert correct build_absolute_uri(), get_host(), and get_full_path() behavior.
  • Align parser and pagination tests with HttpRequest.body being bytes and the host being testserver.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
ninja/testing/client.py Reworks TestClient request building to use a real HttpRequest and provide more realistic request attributes/META.
tests/test_test_client.py Adds/updates tests covering build_absolute_uri, host resolution, and full path/query handling.
tests/test_parser.py Updates parser test to treat request.body as bytes.
tests/test_pagination.py Updates expected pagination links to use testserver and include the resolved path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

},
)
request.META.update({
f"HTTP_{k.replace('-', '_')}": v
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When translating headers into request.META, the generated WSGI keys preserve the original header casing (e.g., X-Forwarded-For becomes HTTP_X_Forwarded_For). Django and code in this repo (e.g., throttling) expect uppercase HTTP_... keys like HTTP_X_FORWARDED_FOR, so some headers won’t be visible via request.META.get(...). Consider uppercasing the normalized header name when building the HTTP_ keys.

Suggested change
f"HTTP_{k.replace('-', '_')}": v
f"HTTP_{k.replace('-', '_').upper()}": v

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. This was an existing bug, but I'll fix it here.

Comment on lines +165 to +177
request.META = request_params.pop(
"META",
{
"REQUEST_METHOD": request.method,
"SCRIPT_NAME": "",
"PATH_INFO": request.path_info,
"QUERY_STRING": request.GET.urlencode(),
"SERVER_NAME": "testserver",
"SERVER_PORT": "80",
"SERVER_PROTOCOL": "HTTP/1.1",
"REMOTE_ADDR": "127.0.0.1",
},
)
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a caller passes a custom META dict, it completely replaces the default WSGI-like environment. That can reintroduce the original issue (missing required keys such as SERVER_NAME, SERVER_PORT, PATH_INFO, QUERY_STRING) and lead to KeyError in Django helpers. Consider starting from the default META and updating it with the user-provided META (rather than replacing it).

Suggested change
request.META = request_params.pop(
"META",
{
"REQUEST_METHOD": request.method,
"SCRIPT_NAME": "",
"PATH_INFO": request.path_info,
"QUERY_STRING": request.GET.urlencode(),
"SERVER_NAME": "testserver",
"SERVER_PORT": "80",
"SERVER_PROTOCOL": "HTTP/1.1",
"REMOTE_ADDR": "127.0.0.1",
},
)
default_meta = {
"REQUEST_METHOD": request.method,
"SCRIPT_NAME": "",
"PATH_INFO": request.path_info,
"QUERY_STRING": request.GET.urlencode(),
"SERVER_NAME": "testserver",
"SERVER_PORT": "80",
"SERVER_PROTOCOL": "HTTP/1.1",
"REMOTE_ADDR": "127.0.0.1",
}
user_meta = request_params.pop("META", None)
if isinstance(user_meta, dict):
default_meta.update(user_meta)
request.META = default_meta

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the existing behavior. I don't think it should change.

It seems like this supports a powerful escape hatch for users. If they want to pass META=... (which is not typically done), they take full responsibility for ensuring it's valid. Alternatively, they might want to deliberately create an invalid request.META for some test.

Comment on lines +168 to +172
"REQUEST_METHOD": request.method,
"SCRIPT_NAME": "",
"PATH_INFO": request.path_info,
"QUERY_STRING": request.GET.urlencode(),
"SERVER_NAME": "testserver",
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QUERY_STRING is derived from request.GET.urlencode(), which can change the raw query string from the URL (e.g., ?emptyparam becomes emptyparam=). That affects request.get_full_path() and any code that relies on the original query string. Consider preserving the original query_string from path when it is provided, and only using urlencode() when building the query from query_params.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This an extremely niche case. The user must be setting query strings directly in the path string (not using a query_params dict), the query string must have an empty parameter (like ?emptyparam), and the test needs to be disrupted by the fact that request.META.QUERY_STRING would be emptyparam= instead of emptyparam.

However, sure, I'll fix it to preserve the string exactly. Maybe someone downstream would want to test very specific things with query strings.

@brianhelba
Copy link
Author

I've resolved some Mypy errors that were pre-existing, but which the change from Mock -> HttpRequest had revealed. I also responded to Copilot's feedback, which helped me to fix some additional pre-existing bugs (present before this PR).

@vitalik Could you please re-trigger CI and review this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants