Chapter 22 · The test framework

22.10 The test client: Client and ClientHandler

The test client, Client, sends a request through the project’s middleware and views by a handler of its own, ClientHandler, with no server, and returns the response with what the request left behind: the templates rendered and their contexts, the exception reported where it is not raised in the test, the match of the URL, the redirects followed. AsyncClient and AsyncClientHandler do the same under ASGI.

Client is a RequestFactory whose Client.request hands the environ to the handler where the factory’s would make a WSGIRequest (Building a request: RequestFactory), and a ClientMixin, which holds what it shares with AsyncClient, the logins among it (Sessions and logins in the test client: ClientMixin.login and force_login). Client.__init__ takes enforce_csrf_checks=False and raise_request_exception=True by default, and makes the handler.

One request, in order

Client.get, like the client’s other methods by HTTP method, keeps its extra environ keys and headers on the client, where assertRedirects reads them (Assertions on a response: assertContains, assertRedirects and assertTemplateUsed), builds the request with the factory’s method of the same name, and with follow=True follows redirects. In the order a run printed it:

Figure 22.3 One Client.get of "/ships/" as it ran: the factory’s methods build the environ, Client.request listens while ClientHandler plays the server, and the response is annotated on the way back.

The harbour office’s tests of the client

A test module of the office, with a view that raises in a URLconf of its own:

# harbour/office/tests/test_client.py
from django.test import Client, TestCase, override_settings
from django.urls import include, path

from harbour.fleet.models import Port, Ship


def broken(request):
    raise LookupError("no berth for Endeavour")


urlpatterns = [path("broken/", broken), path("", include("harbour.urls"))]


@override_settings(ROOT_URLCONF=__name__)
class ClientTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        valletta = Port.objects.create(name="Valletta", country="Malta")
        cls.endeavour = Ship.objects.create(name="Endeavour", tonnage=366, home=valletta)

    def test_list(self):
        response = self.client.get("/ships/")
        self.assertEqual([t.name for t in response.templates], ["office/ship_list.html", "office/base.html"])
        self.assertQuerySetEqual(response.context["ships"], [self.endeavour])

    def test_exception(self):
        with self.assertRaisesMessage(LookupError, "no berth for Endeavour"):
            self.client.get("/broken/")
        response = Client(raise_request_exception=False).get("/broken/")
        self.assertEqual(response.status_code, 500)
        self.assertIs(response.exc_info[0], LookupError)

    def test_follow(self):
        response = self.client.get("/ships/new/", follow=True)
        self.assertEqual(response.redirect_chain, [("/accounts/login/?next=/ships/new/", 302)])

    def test_chain_is_kept(self):
        self.client.get("/ships/")
        with self.settings(MIDDLEWARE=[]):
            kept = self.client.get("/ships/")
            fresh = Client().get("/ships/")
        self.assertTrue(hasattr(kept.wsgi_request, "session"))
        self.assertFalse(hasattr(fresh.wsgi_request, "session"))
$ python manage.py test harbour.office.tests.test_client
Found 4 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.027s

OK
Destroying test database for alias 'default'...

Listening while the handler runs: Client.request

Client.request builds the environ with RequestFactory._base_environ and connects two receivers before it calls the handler, disconnecting both in a finally. To template_rendered, which the test environment’s render of a template sends as the template begins (The test environment: setup_test_environment), it connects store_rendered_templates bound to a new dictionary, which keeps each template and a copy of its context, made, the docstring says, to be accurate at the time of rendering. To got_request_exception it connects ClientMixin.store_exc_info, which keeps sys.exc_info() on the client.

The server’s part: ClientHandler.__call__

ClientHandler is a BaseHandler whose __call__ takes the environ and returns the response object itself:

  1. If BaseHandler._middleware_chain is still None, it calls BaseHandler.load_middleware, which, a comment says, could not be done earlier, the settings not being available. So the chain is built from MIDDLEWARE as it is at the client’s first request, and kept, as test_chain_is_kept shows (Building the middleware chain).
  2. It sends request_started with close_old_connections disconnected, so that receiver closes none of the test’s connections (Opening and closing a connection).
  3. It makes a WSGIRequest and sets "_dont_enforce_csrf_checks" on it, true unless the client enforces the check: a “sneaky little hack”, the comment says, probably required for backwards compatibility with external tests of the admin’s views (The CSRF check: CsrfViewMiddleware.process_view).
  4. BaseHandler.get_response runs the chain.
  5. conditional_content_removal empties the body of a response to a "HEAD" and of a 1xx, 204 or 304, as, its docstring says, most web servers do; the headers stay.
  6. It sets HttpResponseBase.wsgi_request.
  7. It closes the response, which sends request_finished, close_old_connections again off. A streaming response is closed instead by a wrapper of its content, an async one’s by an async wrapper, once that has been read through.

Unlike WSGIHandler.__call__ (The two entry points), it sets no script prefix: under a client given SCRIPT_NAME="/app", the request’s path begins “/app/”, and reverse in the view still returns “/ships/”.

What the response carries

Then Client.request gives the response HttpResponseBase.client; HttpResponseBase.request, the keys RequestFactory.generic passed, not the whole environ; HttpResponseBase.templates, in the order they began to render, a page before the template it extends; HttpResponseBase.context; HttpResponseBase.json; and HttpResponseBase.resolver_match, attributes no response class declares. The response’s cookies are merged into the client’s, for its next request.

context is a ContextList of the copies, a list that looks a string key up in each context in turn. Where one render was reported, its context replaces the list, a step the comment keeps only for backwards compatibility; where none was, context is None.

json is ClientMixin._parse_json bound to the response. It raises ValueError unless the Content-Type begins "application/json", or "application/" and a subtype with the suffix "+json", and keeps what it parsed: a second call returns that, whatever its arguments.

resolver_match is a SimpleLazyObject that, when first read, calls resolve on the request’s "PATH_INFO", with the URLconf set on the request if any (Client.request; Lazy objects: LazyObject and SimpleLazyObject). It does not reuse the handler’s match: a path that matches nothing raises Resolver404 when the object is first used (Resolving a path).

Exceptions: got_request_exception and raise_request_exception

The handler turns an exception raised in the chain into a response (How an exception becomes a response), so the client learns of one only by got_request_exception, sent where an exception is about to become a 500. An Http404, a PermissionDenied, a BadRequest, a SuspiciousOperation or a MultiPartParserError reaches the test only as the 4xx response made for it, and an exception a middleware’s process_exception answered only as that answer.

ClientMixin.check_exception sets HttpResponseBase.exc_info, the kept triple or None, clears the client’s, and raises the exception if raise_request_exception=True. By then the request has run to its end, the 500 response and the middleware’s way out included, and the test gets the exception itself, with its traceback, in place of the response; with raise_request_exception=False, it gets the 500.

Following redirects

With follow=True, Client._handle_redirects gives the first response an empty HttpResponseBase.redirect_chain and, while the status is 301, 302, 303, 307 or 308, follows the redirect, carries the chain over and checks it. ClientMixin._follow_redirect appends the response’s url and status and sends the next request from the same client, cookies and all; an absolute URL’s scheme, host and port go into the environ, and a relative path is joined to the last. A 307 or 308 repeats the method and the data, with the URL’s query only for a method other than "GET" and "HEAD"; any other redirect is a "GET" of the URL’s query.

ClientMixin._ensure_redirects_not_cyclic raises RedirectCycleError when the last step, URL and status, was taken before, or the chain is longer than 20: so long a chain, the comment says, is likely a loop whose path, view or query changes, and 20 is Firefox’s limit.

AsyncClient and AsyncClientHandler

AsyncClient is a ClientMixin and an AsyncRequestFactory, which builds an ASGI scope in place of an environ; AsyncClient.request is Client.request as a coroutine. AsyncClientHandler.__call__ builds the chain with load_middleware(is_async=True), sends request_started with asend, makes an ASGIRequest, awaits BaseHandler.get_response_async, sets HttpResponseBase.asgi_request, and closes a response that does not stream through sync_to_async. No part of ASGIHandler runs (Sync and async in one chain). Its methods take follow=True, through AsyncClient._ahandle_redirects, against the class’s docstring, but ClientMixin._follow_redirect reads "PATH_INFO" and "REQUEST_METHOD" from the request, which a scope lacks: a relative redirect, a 307 or a 308 raises KeyError.