0%

Capstone: The Reading List Service and Client · capstone

Capstone project: Prove It with Tests

Client and server agree on all eleven commands. That proves the code works today. “Finished” asks for one more thing: if someone changed one line next month and broke a row of the contract, a test would notice. The tests that came with the project cover the rows from Chapters 4 to 10, and none of them looks at the three rows you restored and built in this chapter. This lesson adds those tests.

What Check my work does

Check my work grades this lesson in two parts.

First it repeats every check of Lessons 1 to 3, each as its own check, and makes sure SERVICE_CONTRACT.md and README.md are still in the project and that no file in tests/ imports socket, http.server, http.client, anything from urllib, requests, or httpx, or names practice-api. A test that needs one of requests’ classes reaches it through the client, as reading_client.requests.ConnectTimeout, the way test_no_answer_exits_1 already does.

Then it runs your tests with pytest, all of tests/: once against your code as it is, and once for each of six versions of your code in which one makes one realistic mistake:

  1. handle answers /v0/books/{id} with a 308 whose location is /books, without the id;

  2. cached_show asks for a stale entry without If-None-Match;

  3. main returns 0 when the server answered with a 4xx or 5xx status;

  4. call sends its request with no timeout;

  5. handle sends the whole body with its 304;

  6. create_book answers 201 with no location.

Your tests must all pass the first time, and at least one must fail every other time. Two of the six are caught by tests you already have: test_an_error_status_exits_2_with_one_line fails against number 3, and test_call_sends_one_request_with_a_timeout against number 4. The other four need new tests.

During every run, Check my work makes sure no test reaches a network or opens a . reading_client.requests can raise its exceptions but cannot send anything, so a test that calls main without a session fails with a message telling it to use the session it is given. reading_server.http cannot bind a port. Each version is swapped in through its , so call reading_server.handle(...) and reading_client.main(...) as the files already do: a from reading_client import main line, or a function kept in a name at the top of a file, keeps the original and hides the swap.

Each run stops at its first failing test, and a run still going after 3 seconds is stopped. After 8 seconds in all, no further run starts, and the message names the versions already tried.

Your tests pass. Check my work swaps in a cached_show that never sends If-None-Match, and your tests still pass. What does that mean?

Six tests

Write at least one test for each half of each row in “Not built yet”. Any names work, and pytest finds every function whose name starts with test_. The project needs at least 15 tests in all: the 9 that came with it and at least six more. The plan below has seven, because revalidation is worth two.

The server halves go in tests/test_server.py, through handle and the file’s own make_request(method, target, data=None, headers=None), as Chapter 5 taught. Here is the first, for the old path:

def test_legacy_path_keeps_the_id_and_query():
    """Any method on /v0/books/2?x=1 gets a 308 to /books/2?x=1 and changes nothing."""
    state = reading_server.sample_state()
    for method in ["GET", "PUT", "DELETE"]:
        response = reading_server.handle(make_request(method, "/v0/books/2?x=1", None, WRITER), state)
        assert response["status"] == 308
        assert response["headers"]["location"] == "/books/2?x=1"
        assert response["body"] == b""
    assert state == reading_server.sample_state()

The 304 test asks for /books/1 once, takes the etag from that response, and sends it back in If-None-Match: the second response must be a 304 with the same tag and the body b"". The location test posts a book with the writer’s token and compares the 201’s location with /books/3, the third book’s path.

The client halves go in tests/test_client.py, with fakes.fake_session(replies, calls): it answers each request with the next reply and appends a per call to calls, so calls[1]["headers"] is what the second request sent. fakes.fake_response(status, data, headers=..., url=..., history=...) builds a reply, and its headers are case-insensitive, as requests’ are. The file’s run_main(argv, session, tmp_path, now=0) runs main with a cache file inside tmp_path, the folder pytest makes for each test, as in Python in Practice.

  • Revalidation needs two tests. cached_show sends If-None-Match in the request’s headers=, never on the session, so that is where your test looks. One shows the same book at 0 and 59 with a reply that says max-age=60, and asserts one request and two printed lines. The other stores the book at 0, shows it again at 60 with a 304 as the second reply, and asserts that calls[1]["headers"]["if-none-match"] is the stored tag.

  • New books: an add whose reply has url="http://shelf.test:8123/books" and location: /books/3 must print ... at http://shelf.test:8123/books/3.

  • Old paths: an update whose reply has a 308 in history must print followed 308 from and that 308’s on standard error, and still return 0. pytest’s capsys fixture captures both streams: capsys.readouterr().err.

A fake session with no reply left raises an error at once instead of waiting.

Your task

  1. Add the three server tests to tests/test_server.py and the four client tests to tests/test_client.py.

  2. Run the suite until it passes:

python -m pytest -q
echo $?

pytest’s own exit status is 0 when every test passed and 1 when one failed, so echo $? prints 0 once the suite is green.

  1. Press Check my work.

If part one fails, its message names the rule from Lessons 1 to 3. If part two says your tests passed against a mistaken version, it names the mistake and the kind of test that would notice it. Ask Monty to read that message beside your test file with you, without writing the test.

When every check passes, the project is done: a server and a client that meet one contract, and tests that would catch six realistic ways to break it. The next lesson takes it off the site.

Task

Prove the capstone with tests that would notice a broken row.

Add at least six tests, one or more for each half of each row in “Not built yet”: in tests/test_server.py, through reading_server.handle and make_request, the 308 for a /v0/books/{id} path with its exact location, the bodiless 304 for a matching If-None-Match, and the 201’s location; in tests/test_client.py, through fakes.fake_session and run_main with tmp_path, a fresh entry that sends nothing, a stale entry asked for with its stored tag, the absolute after add, and a followed line on standard error. Parametrized tests are fine. Keep the 9 tests that came with the project, so pytest collects at least 15.

Every test calls the project’s through their (no from reading_server import or from reading_client import line, and no module-level name holding handle, create_book, cached_show, main, or call), passes a session to main, and imports nothing that reaches a network: no socket, http.server, http.client, urllib, requests, or httpx. Keep every server and client row from Lessons 1 to 3. Run python -m pytest -q in the .

Then press Check my work. Part one repeats every check of Lessons 1 to 3, and checks that SERVICE_CONTRACT.md and README.md are there and what the files in tests/ import. Part two runs your tests with pytest, with reading_client.requests unable to send and reading_server.http unable to bind a : once against your code, where every test must pass, then once each with one of these functions replaced by a version with one mistake, in this order: handle sending a 308 whose location drops the id; cached_show asking for a stale entry without If-None-Match; main returning 0 for an error status; call with no timeout; handle sending a body with its 304; create_book with no location. Each time, a test must fail. Each run stops at its first failure; a run still going after 3 seconds is stopped, and no further run starts after 8 seconds in all. It does not read your terminal.