0%

Capstone: A Live Scoreboard · capstone

Capstone project: Prove It With Tests

The server and the client each keep their half of the contract, and so far the only proof is Check my work and the transcripts of pl_ws.py. A project you hand to someone else needs its own proof: tests that run with one command and would notice if a rule broke. A rule nobody tests is a rule the next change can break without anyone seeing it.

Nothing in this lesson opens a , starts a thread or waits for a clock. Every test calls your directly with stand-ins, runs in milliseconds and gives the same answer every time.

The stand-ins you are given

tests/test_scoreboard.py already holds two helpers. make_connection(conn_id) is Chapter 7’s stand-in viewer: an with an id whose send records each message, parsed, in its sent . headers_with(*values) builds holding one Authorization entry per , the way the library builds them, so check_request can read them with get_all.

tests/test_client.py holds three. snapshot(version, owls) returns the text of a snapshot with one team. connect_to(messages, calls) returns a stand-in for the library’s connect: its connection hands out messages one by one and then raises the close with 1000 the library would raise, and it records the options connect was called with and every score sent. It returns contextlib.nullcontext(connection), which gives the client’s with block something to enter that hands back the stand-in. refused_with(status) returns a connect that raises InvalidStatus for that status, as the library does when a server refuses the .

Two more come from pytest itself: name a test’s capsys and pytest passes in an object whose readouterr() returns what the test printed so far, as out and err; name it tmp_path and you get a fresh folder for files the test writes. You met both in Python in Practice.

Five tests

Each test arranges a board or a stand-in, acts once, and asserts what the contract says. The join test, for example:

def test_a_new_viewer_starts_with_the_whole_board():
    board = scoreboard_server.make_board(["Owls", "Foxes"])
    scoreboard_server.apply_score(board, {"type": "score", "team": "Owls", "points": 4})
    outbox = scoreboard_server.add_viewer(board, make_connection("v1"))
    first = scoreboard_server.take(outbox)
    assert first["version"] == 1
    assert first["scores"] == {"Owls": 4, "Foxes": 0}

The other four, in words:

  • Broadcast. Add two viewers, apply one score, and assert that take on each outbox gives the new version. With one viewer, a broadcast that stops early could not show.

  • Refusal. Call check_request for /score with no token and with a wrong one, each giving 401; with the right one, giving None; and for /watch with no token, giving None.

  • Staleness. Run scoreboard_client.main(["watch", "--url", ..., "--max-messages", "5"], connect=...) through connect_to with versions 3, 2, 3, 4, and assert that exactly two lines were printed, for 3 and 4, and that it returned 0.

  • A refused token. Write a token into a file under tmp_path, run main(["score", ...]) with --token-file pointing at it and refused_with(401), and assert exit status 1, 401 on standard error, and the token on neither stream. The supplied main reads that file with read_token, which is three lines:

def read_token(path):
    with open(path, encoding="utf-8") as file:
        return file.read().strip()

Call every function through its , scoreboard_server.add_viewer(...) and scoreboard_client.main(...), never through a name imported with from. Chapter 7 gave the reason, and it matters more here: Check my work swaps in other versions of your functions through the module, and a name imported with from would keep the original.

Why do these tests use stand-in connections instead of starting the server and connecting to it?

What Check my work does

Check my work runs your tests twice over. First as they are, against your own code: every test must pass. Then once for each of eight versions of your code that each make one realistic mistake, with one function replaced:

  1. a new viewer’s outbox starts empty, so it is sent nothing until the next score;

  2. a new viewer’s join snapshot has the board’s version but every team at 0 points;

  3. a score is offered to the first viewer only;

  4. anyone may score, with or without a token;

  5. watch prints a snapshot that is not newer than the last one;

  6. score reports success when the handshake is refused;

  7. an outbox keeps the oldest snapshot instead of the newest;

  8. a viewer is removed from the registry only when its connection closes.

Against each of these, at least one of your tests must fail. Three of the eight are also caught by tests you already have from Chapter 8: the empty outbox (a viewer sent nothing waits, and the exit test notices), the oldest snapshot and the removal. The second one is caught only by a join test that checks the scores, so check them. Each run is stopped if a test is still running after three seconds, and a stopped run of a mistaken version counts as noticed.

These tests prove the rules one call at a time. They run no thread, so they cannot show the race at the join or at the end of the quiz; that part is proven by Check my work’s own checks, which you have already passed in Lessons 1 to 3.

Check my work replaces broadcast with a version that offers the new snapshot to the first viewer only, and your tests still pass. What is missing?

Your task

Replace the three stubs at the end of tests/test_scoreboard.py that Lesson 4 names and the two in tests/test_client.py with real tests. Keep their names: the fourth stub in tests/test_scoreboard.py is for Lesson 5, and Check my work leaves it out by name. You may add tests and helpers of your own.

python -m pytest -q
echo $?

When your five tests are written, pytest reports 1 failed and 12 passed, and echo $? still prints 1: the one that fails is Lesson 5’s stub, test_the_last_update_is_sent_before_the_close. Lesson 5 writes it, runs the whole quiz night and checks everything at once.

Task

Replace five assert False stubs with tests, keeping their names. Keep every other test and helper, and keep the Lesson 5 stub, test_the_last_update_is_sent_before_the_close, as it is.

In tests/test_scoreboard.py:

  • test_a_new_viewer_starts_with_the_whole_board: after one score, a viewer added with scoreboard_server.add_viewer finds the whole board, version 1 and every team’s points, in its outbox.

  • test_a_score_is_offered_to_every_viewer: with two viewers added, scoreboard_server.apply_score leaves the new version in both outboxes.

  • test_only_the_token_holder_may_score: scoreboard_server.check_request refuses /score with no token and with a wrong one, and lets the right token and /watch through.

In tests/test_client.py:

  • test_watch_skips_a_snapshot_that_is_not_newer: scoreboard_client.main(["watch", ...], connect=connect_to(...)) given versions 3, 2, 3, 4 prints the lines for 3 and 4 only and returns 0.

  • test_a_refused_token_exits_1_and_says_why: with a token file under tmp_path and connect=refused_with(401), main(["score", ...]) returns 1, writes 401 to standard error, and prints the token on neither stream.

Use the stand-ins you are given; open no , start no thread and wait for nothing. Keep import scoreboard_server and import scoreboard_client, and call their through the inside each test, never with from ... import.

Run python -m pytest -q until only the Lesson 5 stub fails. Then press Check my work. It runs your tests in this process with Lesson 5’s stub left out: once against your own code, where at least 10 tests must be collected and all must pass, then once each with one of eight functions replaced by a version that makes one realistic mistake (a new viewer’s outbox that starts empty, a join snapshot with the board’s version but every team at 0 points, a broadcast to the first viewer only, a check_request that lets anyone score, a watch that prints a stale snapshot, a score that returns 0 on a refused , an offer that keeps the oldest snapshot, and a viewer_handler that removes the viewer only when its connection closes). Against each, at least one test must fail. A run still going after 3 seconds is stopped, and against a mistaken version that counts as noticed. No new run starts once 8 seconds have passed. Any test names may be added, and so may helpers.