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.generic 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.

# 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.__init__ keeps what every request shares: the defaults, environ keys given as keyword arguments, joined by headers given by their real names, through HttpHeaders.to_wsgi_names (Headers, host and scheme), and by query parameters as a url-encoded "QUERY_STRING". RequestFactory.json_encoder is DjangoJSONEncoder by default; RequestFactory.cookies starts as an empty SimpleCookie; RequestFactory.errors is each request’s "wsgi.errors".

RequestFactory._base_environ 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.generic, whose keys RequestFactory.request makes into a WSGIRequest. It hands the path over as a WSGI server would, RequestFactory._get_path 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

Table 22.3 The factory’s methods and their data.
methods what data becomes default content type JSON
RequestFactory.get, RequestFactory.head what data becomes the query string; a ValueError beside query parameters default content type none JSON no
RequestFactory.post what data becomes the body: multipart, or text in the content type’s charset default content type MULTIPART_CONTENT JSON encoded
RequestFactory.put, RequestFactory.patch, RequestFactory.delete what data becomes the body, in DEFAULT_CHARSET default content type "application/octet-stream" JSON encoded
RequestFactory.options what data becomes the body, in DEFAULT_CHARSET default content type "application/octet-stream" JSON not encoded
RequestFactory.trace what data becomes no data argument default content type none JSON no

RequestFactory._encode_json serializes a dictionary, list or tuple with the factory’s encoder when JSON_CONTENT_TYPE_RE matches the start of the content type: "application/json", or an application type with a +json suffix, whatever follows; DjangoJSONEncoder writes a decimal.Decimal and a date as strings (b'{"tonnage": "242.5", "launched": "1820-05-11"}'). post alone then calls RequestFactory._encode_data: 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/octet-stream". 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._base_scope is a minimal scope with, its comment says, a "cookie" header and the "client" address, citing ticket #8551, and the defaults as keys. AsyncRequestFactory.generic fills in the rest, and here the factory’s default query string counts. AsyncRequestFactory.request 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.to_asgi_names, 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.generic).