0%

What a Framework Does for You · practice

Test the App in Process

Your app has two path operations and no way yet to receive a request. In Lesson 10.4 you gave an httpx client a transport of your own, httpx.MockTransport, whose handler answered every request in the same process, with no server and no . ships the same idea pointed at an app. Open tests/test_app.py; its first test already uses it:

def fresh_client(state=None):
    reading_app.reset_state(state)
    return TestClient(reading_app.app)


def test_list_books_is_json():
    response = fresh_client().get("/books")
    assert response.status_code == 200
    assert response.json()["books"][0]["title"] == "Kindred"

TestClient(app) is an httpx client whose transport calls the app directly. get("/books") builds an httpx.Request, the transport hands it to FastAPI as if it had arrived on a connection, and FastAPI’s reply comes back as an httpx.Response with the status_code, headers, and json() you used in Chapter 10. The client’s base is http://testserver, a name nobody looks up: no connection is made, no port is opened, and nothing leaves the Python process. That is why this chapter can test the app without a server.

Chapter 5’s cases, asked again

Here are the read cases from Chapters 4 to 6, asked of your Chapter 9 server in reading_server.py and of the FastAPI app with Lesson 1’s , on the versions this course uses (the ones in requirements.txt):

RequestYour serverThe FastAPI app
GET /books200200
GET /books/2200200
GET /books/99404, {"error": "no book has that id"}404, {"detail":"no such book"}
GET /nowhere404404, {"detail":"Not Found"}
HEAD /books200 with GET’s content-length, no body405, allow: GET
DELETE /books405, allow: GET, HEAD, POST405, allow: GET
GET /books/abc404422
GET /books/301 to /books307 to http://testserver/books

The first four rows answer as before, with a fraction of the code. The last four are decisions FastAPI made for you:

  • HEAD. The @app.get line registers a route for GET only, and FastAPI does not add HEAD to it, so the app answers 405. In Lesson 4.5 you made your own server answer HEAD like GET.

  • The allow . Two routes share the path /books, one for GET and one for POST. When neither accepts the , FastAPI’s 405 lists the methods of only the first route whose path matched. RFC 9110 says Allow lists the methods the resource supports, so this list leaves out POST.

  • /books/abc. The book_id: int annotation from Lesson 1: FastAPI cannot make an int of abc, so it answers 422 before your function runs.

  • The trailing slash. FastAPI redirects /books/ to /books with a 307, to an absolute URL built from the host the request named. A 307 keeps the method, as a 308 does, but says the move is temporary. TestClient follows redirects unless you pass follow_redirects=False, so a plain get("/books/") shows you the 200 at the end.

So the table splits at one line. Find where each request stops on its way in:

FastAPI answers 404, 405, 422 and 307 itself, and only the remaining requests reach your functions, which answer 200 or 404.

GET /nowhere, HEAD /books, DELETE /books, GET /books/abc and GET /books/ never reach your code: FastAPI answers them itself. Only GET /books, GET /books/2 and GET /books/99 call your functions.

DELETE /books gets 405 with allow: GET. What is wrong with it?

HEAD /books gets 405 from the app. Which is true?

Tests that record a decision

The rows for /books, /books/2, and /books/99 test your functions. The others test FastAPI: even the 404 for /nowhere is FastAPI’s, since no route matches. A test that asserts HEAD /books gets 405 does not claim that 405 is right. It records what this app does today, on these versions. If a later FastAPI starts answering HEAD, that test fails, and the failure tells you the framework changed under you, which is worth knowing before a client does. Lesson 2’s second test records two of these answers.

Build the client in each test

fresh_client() resets STATE and builds a TestClient for reading_app.app each time a test calls it. That matters for Check my work: it runs your tests against other versions of the app, each with one mistake, and puts each version in place by replacing reading_app.app. Replacing reading_app.get_book would change nothing, because the route keeps the function it was given when the @app.get line ran. A test that calls fresh_client() looks up reading_app.app when it runs, so it meets whichever version is in place. A client built once at the top of the file, such as client = TestClient(reading_app.app), keeps the original app, and so does from reading_app import app.

Where does TestClient(app).get("/books") send the request?

One warning

Run the tests in the Shell tab:

python -m pytest -q

The summary line ends with 1 warning, and the warnings summary above it has one entry whose last line ends like this:

DeprecationWarning: The anyio.abc.BlockingPortal alias is deprecated, use anyio.from_thread.BlockingPortal instead.

It is from anyio, the library that Starlette, the toolkit FastAPI is built on, uses to run the app’s async code. The notice is about a name Starlette uses inside. It comes from importing the test client, and it does not change a result.

Your task

Replace Lesson 2’s two placeholders in tests/test_app.py, each building its client with fresh_client():

  1. test_unknown_book_is_404: GET /books/99 answers 404. The sample state has books 1 and 2 only.

  2. test_choices_fastapi_makes: HEAD /books answers 405, and GET /books/abc answers 422.

Assert statuses; you may also check allow or your detail. If you want one test to try several requests, pytest.mark.parametrize from Python in Practice is one tidy form, and plain asserts in one function work as well.

Run python -m pytest -q again. It ends 2 failed, 3 passed, 1 warning: the two failures are Lesson 5’s placeholders, assert False, "not written yet", which wait for Lesson 5, and Check my work leaves them out. Then press Check my work. Lesson 3 gives the request body to the model.

Task

Replace Lesson 2’s two placeholders in tests/test_app.py with tests that send requests through fresh_client():

  1. test_unknown_book_is_404: GET /books/99 answers 404. The sample state holds books 1 and 2 only.

  2. test_choices_fastapi_makes: HEAD /books answers 405, and GET /books/abc answers 422. answers both itself.

Build the client inside each test with fresh_client(). Keep import reading_app and call it through the : no from reading_app import ... line, no TestClient(...) at the top of the file, and no name at the top that stores reading_app.app. The tests need only fastapi.testclient, pytest if you like, and reading_app; import no socket, http, urllib, uvicorn, requests, or httpx module. Leave Lesson 5’s two placeholders for Lesson 5.

In the , python -m pytest -q runs the tests. With both written it ends 2 failed, 3 passed, 1 warning; the two failures are Lesson 5’s placeholders.

Then press Check my work. It reads the files in tests/, then runs your tests in this process, leaving out the two Lesson 5 tests, three times: against your own app, where at least three tests must pass and none may fail, then against two versions of the app built from your with one mistake each. In the first, get_book answers 200 with null for an unknown id; in the second, the path segment is declared as text, so /books/abc gets 404 instead of FastAPI’s 422. At least one test must fail against each. A run that takes longer than 3 seconds is stopped and reported. Lesson 1’s list_books and get_book must be written first. Nothing from your terminal is graded.