Chapter 22 · The test framework
22.9 Building a request: RequestFactory
RequestFactory makes a WSGIRequest from a method, a path, data and headers, as a WSGI server would describe the request, for a test to pass straight to a view. A method named after an HTTP method decides what its data becomes, RequestFactory builds the request, and AsyncRequestFactory does the same for an ASGIRequest.
A view is a function of a request, and the plainest test of one calls it. The factory’s docstring says its request can be passed to any view function as though the view had been reached through a URLconf. Client, its subclass, sends the request through a handler instead (The test client: Client and ClientHandler).
What the request has not been through
The request has met no handler, so nothing the handler or the middleware add is on it.
- No session and no user. The session middleware and
AuthenticationMiddlewareset them, so a view underlogin_requiredraisesAttributeErroruntil the test sets a user by hand (Requiring a login:login_required,permission_requiredandLoginRequiredMiddleware). - No CSRF check, which is
CsrfViewMiddleware’s: a"POST"without a token reaches the view, unless the view is decorated withcsrf_protect, which makes the check itself; the factory does not mark its request to be let through, as the client’s handler does, andLoginViewanswered such a"POST"with 403 (The CSRF check:CsrfViewMiddleware)..process_view - No resolving.
HttpRequeststays.resolver_match None, and the test passes the view its URL’s arguments. - No exception made a response. ship_detail, called for a missing ship, raised
Http404into the test (How an exception becomes a response). - The host is
"testserver", whichHttpRequestaccepts once the test environment has added it to.get_host ALLOWED_HOSTS(The test environment:setup_test_environment).
# harbour/office/tests/test_direct.py
from django.contrib.auth.models import User
from django.test import RequestFactory, TestCase
from harbour.fleet.models import Port
from harbour.office.views import new_ship
class NewShipTests(TestCase):
def test_new_ship(self):
valletta = Port.objects.create(name="Valletta", country="Malta")
data = {"name": "Beagle", "tonnage": 242, "home": valletta.pk}
request = RequestFactory().post("/ships/new/", data)
with self.assertRaisesMessage(AttributeError, "has no attribute 'user'"):
new_ship(request)
request.user = User.objects.create_user("nora")
self.assertEqual(new_ship(request).status_code, 302)
The test passes, with no CSRF token sent.
The environ: RequestFactory.__init__ and _base_environ
RequestFactory keeps what every request shares: the defaults, environ keys given as keyword arguments, joined by headers given by their real names, through HttpHeaders (Headers, host and scheme), and by query parameters as a url-encoded "QUERY_STRING". RequestFactory is DjangoJSONEncoder by default; RequestFactory starts as an empty SimpleCookie; RequestFactory is each request’s "wsgi.errors".
RequestFactory is the minimal environ of PEP 3333 and, its comment says, two keys more: "HTTP_COOKIE", the factory’s cookies sorted and joined with "; ", and "REMOTE_ADDR", "127.0.0.1", “often useful”, citing ticket #8551. The server is "testserver" on port "80", and with no "Host" header get_host falls back to that name. The defaults are laid over these keys, and the request’s own over the defaults.
RequestFactory.generic
Every method ends in RequestFactory, whose keys RequestFactory makes into a WSGIRequest. It hands the path over as a WSGI server would, RequestFactory turning its escapes into bytes and decoding them as ISO-8859-1, which WSGIRequest reverses before decoding them as UTF-8 (Making the request: WSGIRequest and ASGIRequest): “/ships/Cáceres/” and “/ships/C%C3%A1ceres/” gave the same path.
The data is made bytes in DEFAULT_CHARSET. Only bytes that are not empty give the request "CONTENT_LENGTH", "CONTENT_TYPE" and a FakePayload holding them as "wsgi.input": generic gives a "GET" no content type. secure=True makes the port "443" and the scheme "https". The call’s headers and other keyword arguments, as environ keys, are laid over the rest, even "CONTENT_LENGTH". The query string is the call’s query parameters url-encoded, a list giving a repeated key ("port=Valletta&flag=red&flag=blue"); else a "QUERY_STRING" keyword; else the path’s own.
The methods
| methods | what data becomes | default content type | JSON |
|---|---|---|---|
Request, Request |
what data becomes the query string; a ValueError beside query parameters |
default content type none | JSON no |
Request |
what data becomes the body: multipart, or text in the content type’s charset | default content type MULTIPART_ |
JSON encoded |
Request, Request, Request |
what data becomes the body, in DEFAULT_CHARSET |
default content type "application/ |
JSON encoded |
Request |
what data becomes the body, in DEFAULT_CHARSET |
default content type "application/ |
JSON not encoded |
Request |
what data becomes no data argument | default content type none | JSON no |
RequestFactory serializes a dictionary, list or tuple with the factory’s encoder when JSON_CONTENT_TYPE_RE matches the start of the content type: "application/, or an application type with a +json suffix, whatever follows; DjangoJSONEncoder writes a decimal.Decimal and a date as strings (b'{"tonnage": "242). post alone then calls RequestFactory: multipart for MULTIPART_CONTENT, else text in the content type’s charset, or DEFAULT_CHARSET. The others leave that to generic, so “Cáceres” as “text/plain; charset=utf-16” arrived as UTF-16 from post and UTF-8 from put. A dictionary under any other content type, or given to options, is sent as its str.
encode_multipart writes a part for each key, lines ended by CRLF, the whole closed by the boundary and two dashes:
# a run of the example: the first lines of the body of a post of {"name": "Beagle", "tonnage": 242, "home": 1}
--BoUnDaRyStRiNg
Content-Disposition: form-data; name="name"
Beagle
--BoUnDaRyStRiNg
Content-Disposition: form-data; name="tonnage"
An iterable that is not a str, bytes among them, gives a part for each item. A file-like value, one with a callable read method, is written by encode_file with its base name, or the key, and a content type: the one the object carries, else a guess from the name, else "application/. A key’s value of None is refused with a TypeError asking whether an empty string or no value was meant; the parser’s side is Multipart parsing and upload handlers.
FakePayload
A body is a FakePayload: bytes that can be written until the first read, and that refuse, with an AssertionError, a read of more than is left. Its docstring’s reason: network data cannot be sought or read past its length, and a view should do nothing under the test client that would fail in real life. Behind a WSGIRequest’s LimitedStream, which stops at "CONTENT_LENGTH", the guard is met when a test sets a length longer than the body.
AsyncRequestFactory
AsyncRequestFactory makes an ASGIRequest with the same synchronous methods; AsyncClient is built on it. AsyncRequestFactory is a minimal scope with, its comment says, a "cookie" header and the "client" address, citing ticket #8551, and the defaults as keys. AsyncRequestFactory fills in the rest, and here the factory’s default query string counts. AsyncRequestFactory wraps the payload in a LimitedStream of its length, so a long read returns what there is.
# a run of the example: three keys of a JSON post's scope, with query_params={"port": "Valletta"} given to the factory and headers={"X-Harbour": "Valletta"} to the post
query_string: 'port=Valletta'
server: ('127.0.0.1', '80')
headers: [(b'host', b'testserver'), (b'content-length', b'18'), (b'content-type', b'application/json'), (b'x-harbour', b'Valletta'), (b'cookie', b'')]
Headers given to a call pass through HttpHeaders, upper case with underscores; the defaults and other keywords count as header names. Each is put in lower case with "_" as "-": ASGIRequest drops a header with an underscore (CVE-2026-3902), and the comment’s reason is not to break tests that give names in that form (Making the request: WSGIRequest and ASGIRequest, AsyncRequestFactory).