0%

requests and httpx, Now That You Know · practice

Test a Client Without a Server

Every check of who_am_i and trail_httpx so far needed a server: pl_http started yours, and the libraries talked to it. Tests should not. In Networks with Python you tested code by handing it a stand-in socket through an injected connect . In Python in Practice you tested a requests function by passing it a stand-in session. Both libraries in this chapter have a place where a stand-in fits, and this lesson uses both.

A transport is the seam

An httpx client does its work in two layers. The client builds the request, follows redirects, keeps , and applies the timeout. Underneath, a transport takes one finished request, gets one response for it, and hands it back. Normally the transport opens a connection. httpx.MockTransport is a transport that calls a function of yours instead:

import httpx

def answer(request):
    if request.url.path == "/v0/books/2":
        return httpx.Response(308, headers={"location": "/books/2"})
    return httpx.Response(200, json={"id": 2, "title": "Café Society", "author": "Ana Ruiz"})

client = httpx.Client(transport=httpx.MockTransport(answer))

The function, often called a handler, receives an httpx.Request and returns an httpx.Response. The request has everything the client would have sent: request.method, request.url.path, request.headers (with host already in it), and request.content, the body as bytes. httpx.Response(200, json=...) encodes the and adds content-type: application/json for you.

Everything above the transport runs as usual. When answer returns the 308 and the call passed follow_redirects=True, the client resolves the location, builds the next request, and calls answer again with path /books/2. No socket is opened at any point; the handler is the whole server.

Passing a function to another function is something you have done since Networks with Python’s injected connect. Here the handler is the , and MockTransport calls it.

What does a MockTransport handler receive and return?

The handler can also see the timeout. httpx attaches it to every request as request.extensions["timeout"], a of the four phases. With the course’s HTTPX_TIMEOUT, httpx.Timeout(2.0, connect=1.0), it is {'connect': 1.0, 'read': 2.0, 'write': 2.0, 'pool': 2.0}; with no timeout given, every phase is httpx’s 5.0. A handler that appends each request, or the parts it cares about, to a lets a test check afterwards what was sent.

How can a test see the timeout an httpx call used?

The session stand-in you know

requests has transports too, but who_am_i takes a whole session, so the stand-in can be a whole session. Open fakes.py. fake_session(replies, calls) returns an with request, get, post, put, and delete, the who_am_i calls. Each call appends a dict to your calls list, with every keyword requests would have received: method, url, params, headers, json, auth, timeout, allow_redirects. Then it answers with the next item of replies, or raises it if that item is an .

fake_response(status, data) builds the replies. It has status_code, json(), case-insensitive headers, and a raise_for_status() that raises requests.HTTPError for 4xx and 5xx, so who_am_i runs against it unchanged:

calls = []
replies = [fakes.fake_response(200, {"user": "ana"}), fakes.fake_response(200, {"user": "ana"})]
session = fakes.fake_session(replies, calls)

After who_am_i runs, calls[0] is the login and calls[1] is /me. A test can now assert what no server would show you directly: the credentials passed in auth, and the timeout of each call.

Test through the module

Your tests are checked too. Check my work runs them against your own side_by_side.py, then again with one function replaced by a version that has a mistake, to see whether a test notices. It puts each version in place by setting it on the , side_by_side.trail_httpx = .... A test that calls side_by_side.trail_httpx(...) looks the name up at the moment it runs, so it meets whichever version is there. A from side_by_side import trail_httpx line copies the original into your test file’s own name, and the swap never reaches it.

The versions with mistakes are the ones this lesson’s two tests are named for: a trail_httpx that does not follow the redirect, and a who_am_i whose GET /me passes no timeout. A test that checks only the login call’s timeout passes for the second one, so check both calls.

Why call side_by_side.trail_httpx(...) instead of importing the name with from side_by_side import trail_httpx?

Your task

Both functions take their stand-in after the * in their definitions, so a test passes it by name, as client=client or session=session:

def trail_httpx(url, *, client=None, timeout=HTTPX_TIMEOUT):
    ...

def who_am_i(base_url, user, password, *, session, timeout=TIMEOUT):
    ...

In tests/test_side_by_side.py, replace the two placeholders that say assert False, "not written yet":

  1. test_trail_httpx_follows_a_redirect: write a handler that answers 308 with a location for one path and 200 for the next, make a client with httpx.MockTransport(handler), call side_by_side.trail_httpx(url, client=client), and assert that the trail is both lines, the 308 first.

  2. test_who_am_i_sends_credentials_and_timeouts: give side_by_side.who_am_i a fakes.fake_session with two replies and a timeout of your choosing, then assert the , the auth of the login call, and the timeout of both calls.

Names and extra tests are up to you. Every test must pass its own stand-in to the function; a test that leaves client or session out would need a network, and fails here. Run only this file, since tests/test_client.py waits for Lesson 6:

python -m pytest -q tests/test_side_by_side.py

It should report 2 passed, or more if you wrote more. Then press Check my work. Lesson 5 looks at the lines both libraries send that nobody asked for.

Task

Test trail_httpx and who_am_i without a server.

In tests/test_side_by_side.py, replace the two placeholders:

  1. test_trail_httpx_follows_a_redirect: a handler answers 308 with a location for one path and 200 for the path it points to. Make a client with httpx.Client(transport=httpx.MockTransport(handler)), call side_by_side.trail_httpx(url, client=client), and assert that the trail holds both lines, the 308 first.

  2. test_who_am_i_sends_credentials_and_timeouts: call side_by_side.who_am_i(...) with fakes.fake_session(replies, calls) and a timeout of your choosing. Assert what it returns, the auth of the login call, and the timeout of both recorded calls, the GET /me as well as the POST /login.

Keep import side_by_side and call the functions through the , inside each test; add no from side_by_side import line and store neither function in a name at the top of the file. Pass a client or a session to every call. Do not import socket, http, or urllib modules. More tests are welcome.

In the , python -m pytest -q tests/test_side_by_side.py runs these tests; with both written it reports 2 passed or more.

Then press Check my work. It reads the file, then runs it three times with pytest: against your own side_by_side.py, against a trail_httpx that does not follow redirects, and against a who_am_i whose GET /me has no timeout. Your tests must all pass against the first, and at least one must fail against each of the others. While they run, side_by_side’s requests and httpx refuse to send anything, so a test that forgets its stand-in fails. A run that takes longer than 3 seconds is stopped and reported. Nothing from your terminal is graded.