Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Low-Level Design (Object-Oriented Design)  Module 7 – Testing your design

Unit tests that check a design

Test a design through its public methods: one test per rule, named after it, refusals first, nine planted bugs, and the same tests in 14 languages.

  • Beginner
  • 20 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7 and Node.js 24.21.0
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

What you will learn

  • Name tests after requirements and lay each one out as arrange, act, assert on a fresh object
  • Write the tests of refusals and state changes first, and test both sides of every rule
  • Test behaviour through public methods, so that a refactoring breaks no test
  • Remove the clock, chance and ordering from tests with times passed in, seeded generators and sorted output
  • Compare how languages report a refusal, as an exception or as a value, and how a test checks each

Before you start

What you’ll learn · Before you start

What you will learn

  • Name tests after requirements and lay each one out as arrange, act, assert on a fresh object
  • Write the tests of refusals and state changes first, and test both sides of every rule
  • Test behaviour through public methods, so that a refactoring breaks no test
  • Remove the clock, chance and ordering from tests with times passed in, seeded generators and sorted output
  • Compare how languages report a refusal, as an exception or as a value, and how a test checks each

Before you start

On this page

Two quick tests of a car that enters, pays and leaves caught 2 of 9 bugs planted in a parking lot; eight tests named after its eight rules caught 8. A unit test of a design calls the objects the way their users will and checks that one rule holds, and in a low-level design (LLD) round such tests are the evidence that the classes do what the requirements say.

You will write that suite for a small lot: one test per rule, each laid out as arrange, act, assert. Then you will measure what it is worth by planting nine bugs, see what a test of private details costs after a refactoring, turn a flaky test into a repeatable one and read the same tests in 14 languages.

A test of a design checks a rule through the front door

Three properties separate a test that checks a design from one that merely runs its code:

The test Kent Beck’s name for it What breaks it
Speaks the language of the requirements: its name starts with the rule it protects, such as test_r5_ticket_is_paid_once behavioural A name like test_pay_2 that says which method ran, not which rule broke
Goes in through the front door: it calls public methods and asserts only what a user of the class can observe structure-insensitive Reading a private field such as _holder, or calling a helper such as _lowest_free()
Gives the same answer on every run deterministic The clock, a random generator, or the order in which a set hands out its items

Kent Beck lists these among the properties of good tests in his Test Desiderata. The unit-testing chapter of Software Engineering at Google gives the same advice in other words: test via public APIs, test behaviours rather than methods, and name tests after the behaviour being tested.

“Unit” here means one class, or a few that work together, run in memory in milliseconds: no database, no network and no files. That is the scale of an LLD answer, and of most of the rules an interviewer asks about.

Eight rules, one test each, in arrange, act, assert

The lot in this lesson has three spots: spot 1 for bikes, spots 2 and 3 for cars. Its public methods are enter(plate, kind, at), which returns a ticket, pay(ticket_id, at), which returns the fee, exit(ticket_id) and free_spots(kind). Times are minutes since the lot opened, and they are always passed in: the lot never reads a clock, the rule from clarifying requirements that lets a test choose any moment. The rules, numbered so that tests can name them:

  • R1 A vehicle gets the lowest-numbered free spot of its kind, and a ticket.
  • R2 When no spot of its kind is free, the vehicle is refused.
  • R3 A plate that is already inside is refused.
  • R4 Each started hour costs 40 rupees for a car and 20 for a bike, and a stay counts as at least one hour: 60 minutes are one hour, 61 minutes are two.
  • R5 A ticket is paid once; a second payment is refused.
  • R6 The barrier opens only for a paid ticket.
  • R7 Once a vehicle has left, its spot is free again.
  • R8 A ticket the lot never issued is refused, at the pay desk and at the barrier.

A ticket goes from active to paid to exited. The diagram puts each requirement where it applies, the three moves on the arrows and each refusal inside the state that it leaves unchanged. Five of the eight rules are refusals:

A ticket goes from no ticket to active, paid and exited; each move and each refusal is labelled with the test that checks it.no ticket entry refused: R2, R3unknown ticket: R8active exit refused: R6paid second payment refused: R5exitedenter: R1pay: R4exit: R7

Each move of a ticket, and each refusal, has a test

Text description of the diagram

The diagram is a state machine of a parking ticket, drawn top to bottom, with four states: no ticket (dashed, before the car enters), active, paid and exited. The three arrows between them are the moves a ticket makes, and each carries the requirement whose test checks it. A refusal leaves the state as it was, so it is written inside the state it refuses in.

  1. In no ticket, two refusals are listed: "entry refused: R2, R3" (no free spot of the vehicle's kind, or the plate is already inside) and "unknown ticket: R8" (paying or leaving with a ticket the lot never issued). The arrow "enter: R1" leads to active: a car gets the lowest free spot of its kind.
  2. In active, "exit refused: R6" stops a car that has not paid. The arrow "pay: R4" leads to paid: the fee is worked out per started hour.
  3. In paid, "second payment refused: R5" stops a ticket from being paid twice. The arrow "exit: R7" leads to exited and frees the spot.

The three moves and the refusals together name all eight requirements, so every part of the diagram has a test.

Each test has the same three parts, the shape Bill Wake named arrange, act, assert: The shape of a focused unit test: arrange builds the state a rule needs, act makes the one call the rule is about, and assert checks what the rule promises.. Arrange builds the state the rule needs, act makes the one call the rule is about, and assert checks what the rule promises. Step through the test of R5:

Interactive · message by message One rule test in arrange, act and assert Step through the R5 test of the lesson's suite, from a fresh lot to the refusal it expects.
One rule test in arrange, act and assert, step 6 of 6. Messages so far: test_r5 to ParkingLot: enter KA01AB1234 at 0; test_r5 to ParkingLot: pay T1 at 45; test_r5 to ParkingLot: pay T1 at 50; ParkingLot to test_r5: LotError: it is paid.test_r5R5 a ticket is paid onceParkingLotT1 paidenter KA01AB1234 at 0pay T1 at 45pay T1 at 50LotError: it is paid

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A fresh lot. setUp() builds a new lot before the test runs, with spot 1 for bikes and spots 2 and 3 for cars, so no other test's cars are in it.
  2. Arrange, the entry. The test makes the state the rule needs. A car enters at minute 0 and gets ticket T1 for spot 2.
  3. Arrange, the payment. The car pays at minute 45, one started hour, 40 rupees. The ticket is now paid, the state in which rule R5 matters.
  4. Act. The one call the rule is about, a second payment at minute 50. The test wraps it in assertRaisesRegex, so act and assert are one statement.
  5. The lot refuses. The lot raises LotError with the message T1 cannot be paid, it is paid, and leaves the ticket as it was.
  6. Assert. assertRaisesRegex sees the error it expected, with the expected message, so the test passes. Had the lot taken the money again, the test would fail and its name would say which rule broke.

When the promise is a refusal, act and assert merge: with self.assertRaisesRegex(LotError, "cannot be paid"): wraps the call, and the test fails if the call returns normally or raises anything else. The whole suite is one method per rule, each name starting with its rule’s number:

The eight requirement tests, with unittest

lot/test_parking.py

"""The eight requirement tests of the parking lot, one per rule, each named after its rule.

Every test arranges a fresh lot, acts once and asserts what the rule promises, using only the lot's public methods.
"""

import sys
import unittest

from parking import LotError, ParkingLot

SPOTS = [(1, "bike"), (2, "car"), (3, "car")]


class LotRules(unittest.TestCase):
    def setUp(self):
        self.lot = ParkingLot(SPOTS)  # a new lot for every test: no test sees another one's cars

    def test_r1_car_gets_lowest_free_car_spot(self):
        """R1: a car gets the lowest-numbered free car spot"""
        # Arrange: the fresh lot of setUp, with car spots 2 and 3 free.
        # Act
        ticket = self.lot.enter("KA01AB1234", "car", at=0)
        # Assert
        self.assertEqual(ticket.spot, 2)

    def test_r2_full_lot_refuses_entry(self):
        """R2: with no free spot of its kind, a vehicle is refused"""
        self.lot.enter("KA01AB1234", "car", at=0)
        self.lot.enter("KA02CD5678", "car", at=5)

        with self.assertRaisesRegex(LotError, "no free car spot"):
            self.lot.enter("KA03EF9012", "car", at=10)

    def test_r3_plate_inside_is_refused(self):
        """R3: a plate that is already inside is refused"""
        self.lot.enter("KA01AB1234", "car", at=0)

        with self.assertRaisesRegex(LotError, "already inside"):
            self.lot.enter("KA01AB1234", "car", at=30)

    def test_r4_each_started_hour_is_charged(self):
        """R4: 60 minutes is one hour, 61 minutes is two; a bike pays half"""
        car_a = self.lot.enter("KA01AB1234", "car", at=0)
        car_b = self.lot.enter("KA02CD5678", "car", at=0)
        bike = self.lot.enter("KA03EF9012", "bike", at=0)

        fees = [self.lot.pay(car_a.id, at=60), self.lot.pay(car_b.id, at=61), self.lot.pay(bike.id, at=61)]

        self.assertEqual(fees, [40, 80, 40])

    def test_r5_ticket_is_paid_once(self):
        """R5: a second payment of a ticket is refused"""
        ticket = self.lot.enter("KA01AB1234", "car", at=0)
        self.lot.pay(ticket.id, at=45)

        with self.assertRaisesRegex(LotError, "cannot be paid: it is paid"):
            self.lot.pay(ticket.id, at=50)

    def test_r6_unpaid_ticket_cannot_leave(self):
        """R6: the barrier stays shut for a ticket that is not paid"""
        ticket = self.lot.enter("KA01AB1234", "car", at=0)

        with self.assertRaisesRegex(LotError, "cannot leave: it is active"):
            self.lot.exit(ticket.id)

    def test_r7_leaving_frees_the_spot(self):
        """R7: once a vehicle has left, its spot is free again"""
        ticket = self.lot.enter("KA01AB1234", "car", at=0)
        self.lot.pay(ticket.id, at=90)

        self.lot.exit(ticket.id)

        self.assertEqual(self.lot.free_spots("car"), 2)

    def test_r8_unknown_ticket_is_refused(self):
        """R8: a ticket the lot never issued is refused at the pay desk and at the barrier"""
        with self.assertRaisesRegex(LotError, "unknown ticket T9"):
            self.lot.pay("T9", at=10)
        with self.assertRaisesRegex(LotError, "unknown ticket T9"):
            self.lot.exit("T9")


if __name__ == "__main__":
    suite = unittest.defaultTestLoader.loadTestsFromTestCase(LotRules)
    unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)

Output

test_r1_car_gets_lowest_free_car_spot (__main__.LotRules.test_r1_car_gets_lowest_free_car_spot)
R1: a car gets the lowest-numbered free car spot ... ok
test_r2_full_lot_refuses_entry (__main__.LotRules.test_r2_full_lot_refuses_entry)
R2: with no free spot of its kind, a vehicle is refused ... ok
test_r3_plate_inside_is_refused (__main__.LotRules.test_r3_plate_inside_is_refused)
R3: a plate that is already inside is refused ... ok
test_r4_each_started_hour_is_charged (__main__.LotRules.test_r4_each_started_hour_is_charged)
R4: 60 minutes is one hour, 61 minutes is two; a bike pays half ... ok
test_r5_ticket_is_paid_once (__main__.LotRules.test_r5_ticket_is_paid_once)
R5: a second payment of a ticket is refused ... ok
test_r6_unpaid_ticket_cannot_leave (__main__.LotRules.test_r6_unpaid_ticket_cannot_leave)
R6: the barrier stays shut for a ticket that is not paid ... ok
test_r7_leaving_frees_the_spot (__main__.LotRules.test_r7_leaving_frees_the_spot)
R7: once a vehicle has left, its spot is free again ... ok
test_r8_unknown_ticket_is_refused (__main__.LotRules.test_r8_unknown_ticket_is_refused)
R8: a ticket the lot never issued is refused at the pay desk and at the barrier ... ok

----------------------------------------------------------------------
Ran 8 tests in 0.000s

OK

This output changes from run to run: unittest prints how long the tests took, which differs from run to run

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 test_parking.py

lot/parking.py

"""A small parking lot, written from the requirements R1 to R8 of the lesson.

Times are minutes since the lot opened, and they are always passed in: the lot never reads a clock, so a test can
choose any moment. Every refusal raises LotError with a message that says what was refused.
"""

from dataclasses import dataclass

RATE_PER_HOUR = {"bike": 20, "car": 40}  # R4: rupees for each started hour


class LotError(Exception):
    """A request that the rules refuse (R2, R3, R5, R6, R8)."""


@dataclass(frozen=True)
class Ticket:
    id: str
    plate: str
    kind: str
    spot: int
    entered_at: int


class ParkingLot:
    def __init__(self, spots):
        """spots: (number, kind) pairs, for example [(1, "bike"), (2, "car"), (3, "car")]."""
        self._kinds = dict(spots)
        self._holder = {}  # spot number -> plate of the vehicle in it
        self._inside = set()  # plates of the vehicles inside
        self._tickets = {}  # ticket id -> Ticket
        self._states = {}  # ticket id -> "active", "paid" or "exited"

    def enter(self, plate, kind, at):
        """Gives the vehicle a spot and a ticket (R1), or refuses it (R2, R3)."""
        if plate in self._inside:
            raise LotError(f"{plate} is already inside")
        spot = self._lowest_free(kind)
        if spot is None:
            raise LotError(f"no free {kind} spot")
        ticket = Ticket(f"T{len(self._tickets) + 1}", plate, kind, spot, at)
        self._holder[spot] = plate
        self._inside.add(plate)
        self._tickets[ticket.id] = ticket
        self._states[ticket.id] = "active"
        return ticket

    def pay(self, ticket_id, at):
        """Returns the fee of an active ticket and marks it paid (R4, R5)."""
        ticket = self._ticket(ticket_id)
        state = self._states[ticket_id]
        if state != "active":
            raise LotError(f"{ticket_id} cannot be paid: it is {state}")
        minutes = at - ticket.entered_at
        hours = max(1, -(-minutes // 60))  # every started hour, and at least one
        self._states[ticket_id] = "paid"
        return hours * RATE_PER_HOUR[ticket.kind]

    def exit(self, ticket_id):
        """Lets a paid ticket's vehicle out and frees its spot (R6, R7)."""
        ticket = self._ticket(ticket_id)
        state = self._states[ticket_id]
        if state != "paid":
            raise LotError(f"{ticket_id} cannot leave: it is {state}")
        self._states[ticket_id] = "exited"
        del self._holder[ticket.spot]
        self._inside.discard(ticket.plate)

    def free_spots(self, kind):
        """How many spots of this kind are free."""
        return sum(1 for number, k in self._kinds.items() if k == kind and number not in self._holder)

    def _ticket(self, ticket_id):
        ticket = self._tickets.get(ticket_id)
        if ticket is None:
            raise LotError(f"unknown ticket {ticket_id}")  # R8
        return ticket

    def _lowest_free(self, kind):
        free = [number for number, k in self._kinds.items() if k == kind and number not in self._holder]
        return min(free, default=None)

Two more conventions carry the suite:

  • A fresh lot for every test. unittest calls setUp() before each test method, so no test sees the cars of another, and the tests pass in any order. (unittest runs them sorted by name, which is why the numbers keep the report in rule order.)
  • One behaviour per test. R4’s test pays three tickets, but they are three examples of one rule, the counting of started hours, so they share a test and one assertion on the list of fees, [40, 80, 40].

Checkpoint

Check: What a test of a design checks · One test per rule, in arrange, act, assert

  1. Question 1 of 3 Which assertion checks rule R7, that leaving frees the spot, through the front door?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: self.assertEqual(lot.free_spots("car"), 2)

    A structure-insensitive test asserts only what a user of the class can observe. After the car leaves, the lot reports two free car spots, whatever data structure it keeps inside.

  2. Question 2 of 3 In the R5 test, which call is the act?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: The second pay(), inside assertRaisesRegex, because R5 is about the second payment

    Arrange is the entry at minute 0 and the payment at minute 45; the act is the payment at minute 50, which the lot must refuse. When the promise is a refusal, the act sits inside the assertion.

  3. Question 3 of 3 A teammate makes one lot at module level and lets every test use it. R2's test fills both car spots. What happens to R1's test, which expects spot 2, when the tests run in order?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: It fails or passes depending on the order the tests run in, because it sees cars that other tests parked

    unittest calls setUp() before each test method, so each test arranges its own fresh lot and the tests pass in any order. A lot shared at module level turns the suite into one long test with a fragile order.

Refusals and state changes first: nine planted bugs

A demo already runs the happy path. In a machine-coding round you show a car entering, paying and leaving, and that path works because you watched it work. What nobody watched is the lot saying no. To measure the difference, catch_bugs.py plants nine bugs in parking.py, one at a time, each a one-line edit of the real source. This is mutation testing: Measuring a test suite by planting small faults in a copy of the code, one at a time, and counting the faults that make at least one test fail. done by hand: tools such as PIT plant the faults automatically, run the tests, and count a fault as killed when some test fails. The script runs the eight rule tests, the two happy-path tests of test_happy_path.py, one more test kept aside for now, and the two structure tests of the next section against every copy:

Nine planted bugs, and the tests that catch them

lot/catch_bugs.py

"""Plants one bug at a time in parking.py and reports which tests catch it.

Each bug is one small edit of the real source, as a mutation-testing tool makes its "mutants". The edited source runs
as a module of its own, and every suite runs against it with that module's ParkingLot and LotError swapped in.
"""

import sys
import types
import unittest
from pathlib import Path

import parking
import test_happy_path
import test_parking
import test_second_visit
import test_structure

# (the rule it breaks, what goes wrong, the text in parking.py, the text that replaces it)
BUGS = [
    ("R1", "takes the highest free spot", "min(free, default=None)", "max(free, default=None)"),
    ("R2", "calls spot 0 free when none is", "min(free, default=None)", "min(free, default=0)"),
    ("R3", "looks for the plate among spots", "if plate in self._inside:", "if plate in self._holder:"),
    ("R4", "rounds the hours down", "-(-minutes // 60)", "minutes // 60"),
    ("R5", "takes a second payment", 'if state != "active":', 'if state == "exited":'),
    ("R6", "lets an unpaid car out", 'if state != "paid":', 'if state == "exited":'),
    ("R7", "keeps a spot after the car left", "del self._holder[ticket.spot]", "self._holder[ticket.spot] = None"),
    ("R8", "lets a KeyError escape", "self._tickets.get(ticket_id)", "self._tickets[ticket_id]"),
    ("R3", "never lets a plate come back", "self._inside.discard(ticket.plate)", "self._inside.discard(ticket.id)"),
]
SUITES = {
    "rules": (test_parking, test_parking.LotRules),
    "happy": (test_happy_path, test_happy_path.HappyPath),
    "again": (test_second_visit, test_second_visit.SecondVisit),
    "inside": (test_structure, test_structure.LotInternals),
}


def planted(number, old, new):
    """A copy of parking.py with one edit, run as a module of its own."""
    source = Path("parking.py").read_text(encoding="utf-8")
    assert source.count(old) == 1, f"bug {number}: {old!r} must appear exactly once in parking.py"
    name = f"parking_bug{number}"
    module = types.ModuleType(name)
    sys.modules[name] = module  # dataclasses looks a class's module up by its name
    exec(compile(source.replace(old, new), f"{name}.py", "exec"), module.__dict__)
    return module


def failing(suite, lot_module):
    """The tests of one suite that fail, or stop with an error, on this version of the lot."""
    test_module, case = SUITES[suite]
    test_module.ParkingLot = lot_module.ParkingLot
    if hasattr(test_module, "LotError"):
        test_module.LotError = lot_module.LotError
    result = unittest.TestResult()
    unittest.defaultTestLoader.loadTestsFromTestCase(case).run(result)
    return sorted(test._testMethodName for test, _ in result.failures + result.errors)


def rule_of(test_name):
    return test_name.split("_")[1].upper()  # "test_r4_each_started_hour_is_charged" -> "R4"


for suite in SUITES:
    assert failing(suite, parking) == [], f"the {suite} tests must pass on the real lot"
results = []
for number, (rule, what, old, new) in enumerate(BUGS, start=1):
    lot = planted(number, old, new)
    results.append((rule, what, {suite: failing(suite, lot) for suite in SUITES}))

print("All 13 tests pass on the real lot. Each line plants one bug in a copy of it.")
print()
print(f"{'breaks':7} {'the bug':32} {'rule tests that fail':21} happy path")
for rule, what, fails in results:
    rules = ", ".join(rule_of(t) for t in fails["rules"]) or "none"
    print(f"{rule:7} {what:32} {rules:21} {'caught' if fails['happy'] else 'missed'}")


def caught(*suites):
    return sum(1 for _, _, fails in results if any(fails[s] for s in suites))


print()
print(f"The 8 rule tests catch {caught('rules')} of {len(BUGS)} bugs; the 2 happy-path tests catch {caught('happy')}.")
print(f"With the second-visit test added, {caught('rules', 'again')} of {len(BUGS)} are caught.")
extra = sum(1 for _, _, fails in results if fails["inside"] and not fails["rules"])
print(f"The 2 structure tests of the next section catch {caught('inside')}, {extra or 'none'} that the rule tests miss.")

Output

All 13 tests pass on the real lot. Each line plants one bug in a copy of it.

breaks  the bug                          rule tests that fail  happy path
R1      takes the highest free spot      R1                    caught
R2      calls spot 0 free when none is   R2                    missed
R3      looks for the plate among spots  R3                    missed
R4      rounds the hours down            R4                    caught
R5      takes a second payment           R5                    missed
R6      lets an unpaid car out           R6                    missed
R7      keeps a spot after the car left  R7                    missed
R8      lets a KeyError escape           R8                    missed
R3      never lets a plate come back     none                  missed

The 8 rule tests catch 8 of 9 bugs; the 2 happy-path tests catch 2.
With the second-visit test added, 9 of 9 are caught.
The 2 structure tests of the next section catch 1, none that the rule tests miss.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 catch_bugs.py

lot/test_happy_path.py

"""Two tests of the kind written in a hurry: the main flow works, nothing is refused."""

import unittest

from parking import LotError, ParkingLot  # noqa: F401 (catch_bugs.py swaps both names for each changed copy)

SPOTS = [(1, "bike"), (2, "car"), (3, "car")]


class HappyPath(unittest.TestCase):
    def test_car_parks_pays_and_leaves(self):
        """a car parks in spot 2, pays 80 for 90 minutes and leaves"""
        lot = ParkingLot(SPOTS)
        ticket = lot.enter("KA01AB1234", "car", at=0)
        self.assertEqual(ticket.spot, 2)
        self.assertEqual(lot.pay(ticket.id, at=90), 80)
        lot.exit(ticket.id)

    def test_bike_parks(self):
        """a bike parks in spot 1"""
        lot = ParkingLot(SPOTS)
        self.assertEqual(lot.enter("KA05GH3456", "bike", at=0).spot, 1)

lot/test_second_visit.py

"""The other side of R3: a plate that has left may come back."""

import unittest

from parking import LotError, ParkingLot  # noqa: F401 (catch_bugs.py swaps both names for each changed copy)

SPOTS = [(1, "bike"), (2, "car"), (3, "car")]


class SecondVisit(unittest.TestCase):
    def test_r3_plate_may_enter_again_after_leaving(self):
        """R3: a plate is refused only while it is inside; after leaving it may enter again"""
        lot = ParkingLot(SPOTS)
        first = lot.enter("KA01AB1234", "car", at=0)
        lot.pay(first.id, at=30)
        lot.exit(first.id)

        second = lot.enter("KA01AB1234", "car", at=120)

        self.assertEqual(second.spot, 2)

The two happy-path tests catch 2 of the 9 bugs, the wrong spot and the hours rounded down, because those are the rules an ordinary visit touches. The other seven pass every happy-path test, because an ordinary visit never asks the lot to refuse anything. Each of the seven is caught by exactly one rule test, the one named after the rule it breaks, so the name of the failing test says where to look.

The ninth bug survives all eight rule tests. After leaving, a car can never come back, because exit removes the ticket’s id from the set of plates inside instead of its plate. R3’s test checks that a plate inside is refused; nothing checked that a plate which has left is let in again. That is the other side of R3, and the second-visit test catches it.

Key idea

Test both sides of every rule, refused while inside and let in after leaving, and after every state change test what it promises next: the refusals are where the eight rule tests found seven bugs that a happy path never touches.

Test behaviour, not structure

A refactoring changes how code is built without changing what it does; that is Martin Fowler’s definition, which from a script to an object model used to refactor a billing script safely. Tests of behaviour survive a refactoring. Tests of structure fail although nothing a user could notice has changed. test_structure.py holds two tests of the second kind: one calls the private helper _lowest_free(), the other compares the private dictionary _holder. parking_heap.py is a refactoring that a lot with thousands of spots could need: each kind keeps a heap of its free spot numbers, so the lowest free spot is one heappop away. refactor_check.py runs both suites against the lot before and after:

The same tests before and after a refactoring

lot/refactor_check.py

"""Runs the rule tests and the structure tests against the lot before and after the refactoring."""

import unittest

import parking
import parking_heap
import test_parking
import test_structure


def run(test_module, case, lot_module):
    """Runs one suite against one version of the lot; returns how many passed, and why the others did not."""
    test_module.ParkingLot = lot_module.ParkingLot
    if hasattr(test_module, "LotError"):
        test_module.LotError = lot_module.LotError
    result = unittest.TestResult()
    suite = unittest.defaultTestLoader.loadTestsFromTestCase(case)
    suite.run(result)
    problems = sorted((test._testMethodName, trace.strip().splitlines()[-1]) for test, trace in result.failures + result.errors)
    return suite.countTestCases() - len(problems), suite.countTestCases(), problems


for label, lot_module in [("before (parking.py)", parking), ("after (parking_heap.py)", parking_heap)]:
    print(label)
    for name, test_module, case in [("rule tests", test_parking, test_parking.LotRules), ("structure tests", test_structure, test_structure.LotInternals)]:
        passed, total, problems = run(test_module, case, lot_module)
        print(f"  {name}: {passed} of {total} pass")
        for test_name, last_line in problems:
            print(f"    {test_name}")
            print(f"      {last_line}")

Output

before (parking.py)
  rule tests: 8 of 8 pass
  structure tests: 2 of 2 pass
after (parking_heap.py)
  rule tests: 8 of 8 pass
  structure tests: 0 of 2 pass
    test_entry_writes_the_plate_into_holder
      AttributeError: 'ParkingLot' object has no attribute '_holder'
    test_lowest_free_helper_picks_spot_2
      AttributeError: 'ParkingLot' object has no attribute '_lowest_free'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 refactor_check.py

lot/test_structure.py

"""Two tests that check how the lot is built inside, not what it does."""

import unittest

from parking import ParkingLot

SPOTS = [(1, "bike"), (2, "car"), (3, "car")]


class LotInternals(unittest.TestCase):
    def test_lowest_free_helper_picks_spot_2(self):
        """the private helper _lowest_free picks spot 2 for a car"""
        lot = ParkingLot(SPOTS)
        self.assertEqual(lot._lowest_free("car"), 2)

    def test_entry_writes_the_plate_into_holder(self):
        """entering stores the plate in the private _holder dictionary"""
        lot = ParkingLot(SPOTS)
        lot.enter("KA01AB1234", "car", at=0)
        self.assertEqual(lot._holder, {2: "KA01AB1234"})

lot/parking_heap.py

"""The same lot after a refactoring: each kind of spot keeps a heap of its free spot numbers.

Entering no longer looks at every spot, which matters at a busy gate of a lot with thousands of them: the lowest free
spot of a kind is one heappop away. The public methods, and what they do, are unchanged.
"""

import heapq
from dataclasses import dataclass

RATE_PER_HOUR = {"bike": 20, "car": 40}  # R4: rupees for each started hour


class LotError(Exception):
    """A request that the rules refuse (R2, R3, R5, R6, R8)."""


@dataclass(frozen=True)
class Ticket:
    id: str
    plate: str
    kind: str
    spot: int
    entered_at: int


class ParkingLot:
    def __init__(self, spots):
        """spots: (number, kind) pairs, for example [(1, "bike"), (2, "car"), (3, "car")]."""
        self._free = {}  # kind -> heap of the free spot numbers of that kind
        for number, kind in spots:
            heapq.heappush(self._free.setdefault(kind, []), number)
        self._inside = set()  # plates of the vehicles inside
        self._tickets = {}  # ticket id -> Ticket
        self._states = {}  # ticket id -> "active", "paid" or "exited"

    def enter(self, plate, kind, at):
        """Gives the vehicle a spot and a ticket (R1), or refuses it (R2, R3)."""
        if plate in self._inside:
            raise LotError(f"{plate} is already inside")
        free = self._free.get(kind)
        if not free:
            raise LotError(f"no free {kind} spot")
        ticket = Ticket(f"T{len(self._tickets) + 1}", plate, kind, heapq.heappop(free), at)
        self._inside.add(plate)
        self._tickets[ticket.id] = ticket
        self._states[ticket.id] = "active"
        return ticket

    def pay(self, ticket_id, at):
        """Returns the fee of an active ticket and marks it paid (R4, R5)."""
        ticket = self._ticket(ticket_id)
        state = self._states[ticket_id]
        if state != "active":
            raise LotError(f"{ticket_id} cannot be paid: it is {state}")
        minutes = at - ticket.entered_at
        hours = max(1, -(-minutes // 60))  # every started hour, and at least one
        self._states[ticket_id] = "paid"
        return hours * RATE_PER_HOUR[ticket.kind]

    def exit(self, ticket_id):
        """Lets a paid ticket's vehicle out and frees its spot (R6, R7)."""
        ticket = self._ticket(ticket_id)
        state = self._states[ticket_id]
        if state != "paid":
            raise LotError(f"{ticket_id} cannot leave: it is {state}")
        self._states[ticket_id] = "exited"
        heapq.heappush(self._free[ticket.kind], ticket.spot)
        self._inside.discard(ticket.plate)

    def free_spots(self, kind):
        """How many spots of this kind are free."""
        return len(self._free.get(kind, []))

    def _ticket(self, ticket_id):
        ticket = self._tickets.get(ticket_id)
        if ticket is None:
            raise LotError(f"unknown ticket {ticket_id}")  # R8
        return ticket

The eight rule tests pass on both versions, because the lot behaves the same. Both structure tests fail with an AttributeError: they protected a data structure, not a rule, so every refactoring makes someone rewrite or delete them. They do not pay for that in bugs found either: of the nine bugs planted earlier they catch one, the wrong spot, which R1’s test catches too. R7’s test shows the alternative, checking through free_spots() that leaving frees the spot, which any version of the lot must answer correctly.

Languages differ in how far they keep tests away from private details:

Language Can a test read private details?
Python Yes: a leading underscore is a convention, and a test can read _holder whenever it likes
Go Not when the test file declares the package parking_test: the compiler then lets it use only what the package exports, which Go’s documentation calls black-box testing
Java Not private fields, but a test class in the same package can use package-private members
Rust The unit tests in a file’s mod tests see its private items; tests in a crate’s tests folder see only the public API

When a private helper holds the hardest logic, such as the fee rules of a real tariff, do not test it through the back door. Give it a public home: a small FeePolicy class with its own method and its own tests, which the lot then uses.

Checkpoint

Check: Refusals and both sides of every rule · Behaviour through public methods, not structure

  1. Question 1 of 3 Which of these planted bugs did the two happy-path tests catch?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: The hours rounded down, because an ordinary visit pays for its stay

    The happy-path tests caught the wrong spot and the rounding, the two rules an ordinary visit touches. The other seven bugs are in refusals and state changes, which only the rule tests asked for.

  2. Question 2 of 3 After leaving, a car could never come back, and all eight rule tests passed. Which test catches the bug?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: A test that lets a plate leave and then enter again, the other side of R3

    exit removed the ticket's id from the set of plates inside instead of the plate. Testing both sides of every rule, and what each state change promises next, catches it.

  3. Question 3 of 3 After the refactoring to a heap of free spots, which tests failed?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: The two structure tests, with an AttributeError, while all eight rule tests passed

    refactor_check.py printed 8 of 8 rule tests passing before and after, and 0 of 2 structure tests after. The structure tests protected a data structure, not a rule.

Tests that give the same answer every time

A flaky test: A test that passes and fails without any change to the code, usually because it depends on the clock, on chance, on an order nothing promises or on state another test left behind. passes and fails without any change to the code. In an LLD answer the cause is almost always one of four:

  • The clock. A class that reads the current time prices the same stay differently depending on when the test runs. Pass the time in, as this lot does, or give the class a clock object.
  • Chance. A class that calls the random module’s functions uses a generator that the test does not control.
  • Order. Python salts the hashes of strings with a random value in every new process, so a set of plates may hand out its items in another order in the next run, and Go’s specification leaves the order of a loop over a map open. Sort before you compare.
  • Shared state. A lot made once at module level and used by several tests makes each test depend on the ones that ran before it. A fresh object in setUp() removes that.

Chance is the least obvious of the four, so here it is measured. SpreadLot gives each car a free spot chosen at random among five, so that wear spreads over the floor, and a test checks that 20 visits use all five spots. Each visit picks spot 3 with chance 1 in 5. Set at least one success and read the chance that spot 3 is used at all:

Interactive · change it and see Does a random spot get used in 20 visits? Each of n independent tries succeeds with probability p. The bars are the chances of each number of successes; the highlighted ones are "at least k".
Probability of each number of successes: probability against successes.00.050.10.150.20.25successesprobability02468101214161820

In 20 tries with p = 0.2, the chance of at least 1 successes is 0.9885; on average there are 4.

P(X ≥ 1) 0.9885
P(X = 1) 0.0576
Mean np and standard deviation 4 ± 1.79
The numbers as a table
The most likely outcomes
SuccessesProbability
00.0115
10.0576
20.1369
30.2054
40.2182
50.1746
60.1091
70.0545
80.0222

The figure reads P(X ≥ 1) = 0.9885, so one given spot is skipped by all 20 visits in about 1.15 % of runs, (4/5)²⁰. Any of the five spots can be the one left out, so the test fails in about 5.7 % of runs, roughly one CI run in 17. It passes on your computer, fails in CI a week later, and passes again when someone presses re-run. flaky_demo.py runs it as 200 CI runs would, each with a new seed:

A test that passes by chance, made repeatable

flaky/flaky_demo.py

"""A test that is almost always true, run the way 200 CI runs would run it, then made repeatable."""

import random

from spread_lot import SpreadLot

SPOTS = [1, 2, 3, 4, 5]


def check_every_spot_gets_used(rng=random):
    """Flaky: 20 visits use all five spots most of the time, not every time."""
    lot = SpreadLot(SPOTS, rng=rng)
    used = set()
    for visit in range(20):
        plate = f"KA01AB{visit:04}"
        used.add(lot.enter(plate))
        lot.leave(plate)
    assert used == set(SPOTS), f"spots never used: {sorted(set(SPOTS) - used)}"


def check_never_a_taken_spot(rng):
    """The rule itself: with four cars inside, the fifth gets the one free spot, whatever the seed."""
    lot = SpreadLot(SPOTS, rng=rng)
    taken = {lot.enter(f"KA01AB{n:04}") for n in range(4)}
    assert lot.enter("KA02CD5678") not in taken


def passes(check, *args):
    try:
        check(*args)
    except AssertionError:
        return False
    return True


def unseeded_run(run):
    random.seed(run)  # stands in for the different seed that every new process gets
    return passes(check_every_spot_gets_used)


ok = sum(unseeded_run(run) for run in range(200))
print(f"Shared generator, new seed per run, 200 runs: {ok} passed, {200 - ok} failed")

always, never = [], []
for seed in range(200):
    first, second = (passes(check_every_spot_gets_used, random.Random(seed)) for _ in range(2))
    assert first == second, f"seed {seed} gave two different results"
    (always if first else never).append(seed)
print(f"random.Random(seed) passed in, each seed twice: {len(always)} always pass, {len(never)} always fail")
try:
    check_every_spot_gets_used(random.Random(never[0]))
except AssertionError as failure:
    print(f"  seed {never[0]}, every time: {failure}")

rule = sum(passes(check_never_a_taken_spot, random.Random(seed)) for seed in range(200))
print(f"Rule test, seeds 0 to 199: {rule} passed, {200 - rule} failed")

Output

Shared generator, new seed per run, 200 runs: 189 passed, 11 failed
random.Random(seed) passed in, each seed twice: 189 always pass, 11 always fail
  seed 26, every time: spots never used: [3]
Rule test, seeds 0 to 199: 200 passed, 0 failed

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 flaky_demo.py

flaky/spread_lot.py

"""A lot that gives each car a free spot chosen at random, so that wear spreads over the whole floor."""

import random


class SpreadLot:
    def __init__(self, spots, rng=random):
        """spots: the spot numbers. rng: where the random choices come from, by default the random module's shared
        generator; a test passes a random.Random of its own, made from a seed it chooses."""
        self._free = sorted(spots)  # kept sorted, so that one seed always gives the same choices
        self._rng = rng
        self._spot_of = {}  # plate -> spot

    def enter(self, plate):
        spot = self._rng.choice(self._free)
        self._free.remove(spot)
        self._spot_of[plate] = spot
        return spot

    def leave(self, plate):
        self._free.append(self._spot_of.pop(plate))
        self._free.sort()

The test failed in 11 of 200 runs, 5.5 %, close to the 5.7 % worked out above. The fix has two steps:

  1. Make it repeatable. The generator becomes a constructor parameter, rng, which defaults to the random module’s shared generator in production; a test passes random.Random(seed) instead. Python’s documentation explains why that works: the module’s functions are bound methods of one hidden Random instance, and an instance of your own shares no state with it. With a seed, 189 seeds always pass and 11 always fail, and seed 26 leaves spot 3 unused on every run, so a failure can be replayed and debugged.
  2. Make it meaningful. Assert the rule, which holds for every seed: a car never gets a spot that is taken. That test passes for all 200 seeds.

Retrying, or keeping a seed that happens to pass

Retrying a failing test hides the cause, and the flaky test stays as it was. Keeping the chancy assertion with a seed that happens to pass ties the test to that seed’s sequence, and Python promises only that random() repeats its sequence for a seed across versions; methods such as choice() may change how they use it.

The same tests in every language

The rules do not depend on the language, and neither do the tests. The program below keeps a parking ticket’s rules R4 to R6 and five tests named after them as one table of text, the same lines in every language: each test is a rule, its steps and what the last step must answer, run on a fresh ticket. All 14 languages printed the same six lines:

One program · 14 languages A ticket's rules, tested the same way in every language

Python 3.14 · main.py

TESTS = """\
R4 60 minutes cost 40 | pay 60 | 40
R4 61 minutes cost 80 | pay 61 | 80
R5 paid only once | pay 45; pay 50 | refused: paid, not active
R6 no exit unpaid | exit | refused: active, not paid
R6 exit when paid | pay 30; exit | exited
"""


class Refused(Exception):
    pass


class Ticket:
    def __init__(self, entered_at: int) -> None:
        self.entered_at = entered_at
        self.state = "active"

    def pay(self, at: int) -> int:
        self._move("active", "paid")
        return max(1, (at - self.entered_at + 59) // 60) * 40  # rupees an hour

    def exit(self) -> None:
        self._move("paid", "exited")

    def _move(self, before: str, after: str) -> None:
        if self.state != before:
            raise Refused(f"{self.state}, not {before}")
        self.state = after


def step(ticket: Ticket, command: str) -> str:
    try:
        if command == "exit":
            ticket.exit()
            return "exited"
        return str(ticket.pay(int(command.removeprefix("pay "))))
    except Refused as refusal:
        return f"refused: {refusal}"


passed = 0
tests = TESTS.splitlines()
for test in tests:
    rule, steps, expected = test.split(" | ")
    ticket = Ticket(entered_at=0)  # a fresh ticket for every test
    got = [step(ticket, command) for command in steps.split("; ")][-1]
    if got == expected:
        passed += 1
        print(f"ok    {rule}")
    else:
        print(f"FAIL  {rule}: got {got}")
print(f"{passed} of {len(tests)} tests pass")

In Python: Refused is the program's own exception, so a test can tell a rule's refusal from a bug such as a TypeError. The list comprehension runs every step, and [-1] keeps the last outcome.

Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py

JavaScript Node 24 · main.mjs

const TESTS = `R4 60 minutes cost 40 | pay 60 | 40
R4 61 minutes cost 80 | pay 61 | 80
R5 paid only once | pay 45; pay 50 | refused: paid, not active
R6 no exit unpaid | exit | refused: active, not paid
R6 exit when paid | pay 30; exit | exited`;

class Refused extends Error {}

class Ticket {
  #state = "active";

  constructor(enteredAt) {
    this.enteredAt = enteredAt;
  }

  pay(at) {
    this.#move("active", "paid");
    return Math.max(1, Math.floor((at - this.enteredAt + 59) / 60)) * 40; // rupees an hour
  }

  exit() {
    this.#move("paid", "exited");
  }

  #move(before, after) {
    if (this.#state !== before) {
      throw new Refused(`${this.#state}, not ${before}`);
    }
    this.#state = after;
  }
}

function step(ticket, command) {
  try {
    if (command === "exit") {
      ticket.exit();
      return "exited";
    }
    return String(ticket.pay(Number(command.slice("pay ".length))));
  } catch (error) {
    if (!(error instanceof Refused)) throw error;
    return `refused: ${error.message}`;
  }
}

let passed = 0;
const tests = TESTS.split("\n");
for (const test of tests) {
  const [rule, steps, expected] = test.split(" | ");
  const ticket = new Ticket(0); // a fresh ticket for every test
  const got = steps
    .split("; ")
    .map((command) => step(ticket, command))
    .at(-1);
  if (got === expected) {
    passed += 1;
    console.log(`ok    ${rule}`);
  } else {
    console.log(`FAIL  ${rule}: got ${got}`);
  }
}
console.log(`${passed} of ${tests.length} tests pass`);

In JavaScript: A refusal is a Refused, a subclass of Error, and step() throws anything else on, so a bug is never mistaken for a refusal.

Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs

Java 25 LTS · Main.java

public class Main {
  static final String TESTS =
      """
      R4 60 minutes cost 40 | pay 60 | 40
      R4 61 minutes cost 80 | pay 61 | 80
      R5 paid only once | pay 45; pay 50 | refused: paid, not active
      R6 no exit unpaid | exit | refused: active, not paid
      R6 exit when paid | pay 30; exit | exited
      """;

  static final class Ticket {
    private final int enteredAt;
    private String state = "active";

    Ticket(int enteredAt) {
      this.enteredAt = enteredAt;
    }

    int pay(int at) {
      move("active", "paid");
      return Math.max(1, (at - enteredAt + 59) / 60) * 40; // rupees an hour
    }

    void exit() {
      move("paid", "exited");
    }

    private void move(String before, String after) {
      if (!state.equals(before)) {
        throw new IllegalStateException(state + ", not " + before);
      }
      state = after;
    }
  }

  static String step(Ticket ticket, String command) {
    try {
      if (command.equals("exit")) {
        ticket.exit();
        return "exited";
      }
      return String.valueOf(ticket.pay(Integer.parseInt(command.substring("pay ".length()))));
    } catch (IllegalStateException refusal) {
      return "refused: " + refusal.getMessage();
    }
  }

  public static void main(String[] args) {
    String[] tests = TESTS.split("\n");
    int passed = 0;
    for (String test : tests) {
      String[] part = test.split(" \\| ");
      Ticket ticket = new Ticket(0); // a fresh ticket for every test
      String got = "";
      for (String command : part[1].split("; ")) {
        got = step(ticket, command);
      }
      if (got.equals(part[2])) {
        passed++;
        System.out.println("ok    " + part[0]);
      } else {
        System.out.println("FAIL  " + part[0] + ": got " + got);
      }
    }
    System.out.println(passed + " of " + tests.length + " tests pass");
  }
}

In Java: A refusal is an IllegalStateException, the standard exception for a method called at the wrong time, so the program needs no exception class of its own.

Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java

C++23 · main.cpp

#include <algorithm>
#include <expected>
#include <optional>
#include <print>
#include <ranges>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

constexpr std::string_view tests = R"(R4 60 minutes cost 40 | pay 60 | 40
R4 61 minutes cost 80 | pay 61 | 80
R5 paid only once | pay 45; pay 50 | refused: paid, not active
R6 no exit unpaid | exit | refused: active, not paid
R6 exit when paid | pay 30; exit | exited)";

class Ticket {
public:
  explicit Ticket(int enteredAt) : enteredAt_(enteredAt) {}

  std::expected<int, std::string> pay(int at) {
    if (auto refused = move("active", "paid")) {
      return std::unexpected(*refused);
    }
    return std::max(1, (at - enteredAt_ + 59) / 60) * 40; // rupees an hour
  }
  std::optional<std::string> exit() { return move("paid", "exited"); }

private:
  std::optional<std::string> move(std::string_view before, std::string after) {
    if (state_ != before) {
      return state_ + ", not " + std::string(before);
    }
    state_ = std::move(after);
    return std::nullopt;
  }
  int enteredAt_;
  std::string state_ = "active";
};

std::string step(Ticket &ticket, std::string_view command) {
  if (command == "exit") {
    const auto refused = ticket.exit();
    return refused ? "refused: " + *refused : "exited";
  }
  const auto fee = ticket.pay(std::stoi(std::string(command.substr(4))));
  return fee ? std::to_string(*fee) : "refused: " + fee.error();
}

std::vector<std::string> split(std::string_view text, std::string_view by) {
  return text | std::views::split(by) |
         std::ranges::to<std::vector<std::string>>();
}

int main() {
  const auto lines = split(tests, "\n");
  int passed = 0;
  for (const auto &test : lines) {
    const auto part = split(test, " | ");
    Ticket ticket(0); // a fresh ticket for every test
    std::string got;
    for (const auto &command : split(part[1], "; ")) {
      got = step(ticket, command);
    }
    if (got == part[2]) {
      ++passed;
      std::println("ok    {}", part[0]);
    } else {
      std::println("FAIL  {}: got {}", part[0], got);
    }
  }
  std::println("{} of {} tests pass", passed, lines.size());
}

In C++: pay() returns std::expected, C++23's value-or-error type, and exit() an optional reason, so a refusal is a value that the test compares, as in Go and Rust; nothing is thrown.

Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main

C# 14 · .NET 10 · app.cs

using System.Globalization;

const string Tests = """
    R4 60 minutes cost 40 | pay 60 | 40
    R4 61 minutes cost 80 | pay 61 | 80
    R5 paid only once | pay 45; pay 50 | refused: paid, not active
    R6 no exit unpaid | exit | refused: active, not paid
    R6 exit when paid | pay 30; exit | exited
    """;

string[] tests = Tests.Split('\n');
int passed = 0;
foreach (string test in tests)
{
    string[] part = test.Split(" | ");
    var ticket = new Ticket(0); // a fresh ticket for every test
    string got = "";
    foreach (string command in part[1].Split("; "))
    {
        got = Step(ticket, command);
    }
    if (got == part[2])
    {
        passed++;
        Console.WriteLine($"ok    {part[0]}");
    }
    else
    {
        Console.WriteLine($"FAIL  {part[0]}: got {got}");
    }
}
Console.WriteLine($"{passed} of {tests.Length} tests pass");

static string Step(Ticket ticket, string command)
{
    try
    {
        if (command == "exit")
        {
            ticket.Exit();
            return "exited";
        }
        int at = int.Parse(command["pay ".Length..], CultureInfo.InvariantCulture);
        return ticket.Pay(at).ToString(CultureInfo.InvariantCulture);
    }
    catch (InvalidOperationException refusal)
    {
        return $"refused: {refusal.Message}";
    }
}

class Ticket(int enteredAt)
{
    string state = "active";

    public int Pay(int at)
    {
        Move("active", "paid");
        return Math.Max(1, (at - enteredAt + 59) / 60) * 40; // rupees an hour
    }

    public void Exit() => Move("paid", "exited");

    void Move(string before, string after)
    {
        if (state != before)
        {
            throw new InvalidOperationException($"{state}, not {before}");
        }
        state = after;
    }
}

In C#: A refusal is an InvalidOperationException. The steps run in a foreach loop, because Select(...).Last() on an array would ask only the last element for its value, and the steps before it would never run.

Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs

C23 · main.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static const struct {
  const char *rule, *steps, *expected;
} TESTS[] = {
    {"R4 60 minutes cost 40", "pay 60", "40"},
    {"R4 61 minutes cost 80", "pay 61", "80"},
    {"R5 paid only once", "pay 45; pay 50", "refused: paid, not active"},
    {"R6 no exit unpaid", "exit", "refused: active, not paid"},
    {"R6 exit when paid", "pay 30; exit", "exited"},
};

typedef struct {
  int entered_at;
  const char *state;
} Ticket;

// Moves the ticket on, or writes why it refuses and returns false.
static bool move(Ticket *ticket, const char *before, const char *after,
                 char *out, size_t size) {
  if (strcmp(ticket->state, before) != 0) {
    snprintf(out, size, "refused: %s, not %s", ticket->state, before);
    return false;
  }
  ticket->state = after;
  return true;
}

static void step(Ticket *ticket, const char *command, char *out, size_t size) {
  if (strcmp(command, "exit") == 0) {
    if (move(ticket, "paid", "exited", out, size)) {
      snprintf(out, size, "exited");
    }
  } else if (move(ticket, "active", "paid", out, size)) {
    int minutes = atoi(command + strlen("pay ")) - ticket->entered_at;
    int hours = (minutes + 59) / 60;
    snprintf(out, size, "%d", (hours > 1 ? hours : 1) * 40); // rupees an hour
  }
}

int main(void) {
  const size_t count = sizeof TESTS / sizeof TESTS[0];
  size_t passed = 0;
  for (size_t i = 0; i < count; i++) {
    Ticket ticket = {.entered_at = 0, .state = "active"}; // a fresh ticket
    char steps[64], got[64] = "";
    snprintf(steps, sizeof steps, "%s", TESTS[i].steps);
    for (char *command = strtok(steps, ";"); command;
         command = strtok(NULL, ";")) {
      step(&ticket, command + (command[0] == ' '), got, sizeof got);
    }
    if (strcmp(got, TESTS[i].expected) == 0) {
      passed++;
      printf("ok    %s\n", TESTS[i].rule);
    } else {
      printf("FAIL  %s: got %s\n", TESTS[i].rule, got);
    }
  }
  printf("%zu of %zu tests pass\n", passed, count);
  return 0;
}

In C: C has no exceptions, so move() writes the refusal into the caller's buffer and returns false. With no string split in the standard library, each test is a struct of three strings, and strtok walks the steps.

Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main

PHP 8.5 · main.php

<?php

declare(strict_types=1);

const TESTS = <<<'TXT'
    R4 60 minutes cost 40 | pay 60 | 40
    R4 61 minutes cost 80 | pay 61 | 80
    R5 paid only once | pay 45; pay 50 | refused: paid, not active
    R6 no exit unpaid | exit | refused: active, not paid
    R6 exit when paid | pay 30; exit | exited
    TXT;

final class Refused extends RuntimeException {}

final class Ticket
{
    private string $state = 'active';

    public function __construct(private int $enteredAt) {}

    public function pay(int $at): int
    {
        $this->move('active', 'paid');
        return max(1, intdiv($at - $this->enteredAt + 59, 60)) * 40; // rupees an hour
    }

    public function exit(): void
    {
        $this->move('paid', 'exited');
    }

    private function move(string $before, string $after): void
    {
        if ($this->state !== $before) {
            throw new Refused("{$this->state}, not {$before}");
        }
        $this->state = $after;
    }
}

function step(Ticket $ticket, string $command): string
{
    try {
        if ($command === 'exit') {
            $ticket->exit();
            return 'exited';
        }
        return (string) $ticket->pay((int) substr($command, strlen('pay ')));
    } catch (Refused $refusal) {
        return "refused: {$refusal->getMessage()}";
    }
}

$tests = explode("\n", TESTS);
$passed = 0;
foreach ($tests as $test) {
    [$rule, $steps, $expected] = explode(' | ', $test);
    $ticket = new Ticket(0); // a fresh ticket for every test
    $outcomes = array_map(fn(string $command) => step($ticket, $command), explode('; ', $steps));
    $got = end($outcomes);
    if ($got === $expected) {
        $passed++;
        echo "ok    {$rule}\n";
    } else {
        echo "FAIL  {$rule}: got {$got}\n";
    }
}
echo "{$passed} of " . count($tests) . " tests pass\n";

In PHP: The table is a nowdoc, which PHP never interpolates, and the indentation of its closing marker is removed from every line. array_map runs every step, and end() returns the last outcome.

Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php

Rust 1.99 · main.rs

const TESTS: &str = "\
R4 60 minutes cost 40 | pay 60 | 40
R4 61 minutes cost 80 | pay 61 | 80
R5 paid only once | pay 45; pay 50 | refused: paid, not active
R6 no exit unpaid | exit | refused: active, not paid
R6 exit when paid | pay 30; exit | exited";

struct Ticket {
    entered_at: u32,
    state: &'static str,
}

impl Ticket {
    fn new(entered_at: u32) -> Self {
        Ticket {
            entered_at,
            state: "active",
        }
    }

    fn pay(&mut self, at: u32) -> Result<u32, String> {
        self.advance("active", "paid")?;
        Ok(((at - self.entered_at + 59) / 60).max(1) * 40) // rupees an hour
    }

    fn exit(&mut self) -> Result<(), String> {
        self.advance("paid", "exited")
    }

    fn advance(&mut self, before: &str, after: &'static str) -> Result<(), String> {
        if self.state != before {
            return Err(format!("{}, not {before}", self.state));
        }
        self.state = after;
        Ok(())
    }
}

fn step(ticket: &mut Ticket, command: &str) -> String {
    let outcome = match command.strip_prefix("pay ") {
        Some(at) => ticket
            .pay(at.parse().unwrap_or(0))
            .map(|fee| fee.to_string()),
        None => ticket.exit().map(|()| "exited".to_string()),
    };
    outcome.unwrap_or_else(|refusal| format!("refused: {refusal}"))
}

fn main() {
    let mut passed = 0;
    for test in TESTS.lines() {
        let parts: Vec<&str> = test.split(" | ").collect();
        let [rule, steps, expected] = parts[..] else {
            continue;
        };
        let mut ticket = Ticket::new(0); // a fresh ticket for every test
        let got = steps
            .split("; ")
            .map(|c| step(&mut ticket, c))
            .last()
            .unwrap_or_default();
        if got == expected {
            passed += 1;
            println!("ok    {rule}");
        } else {
            println!("FAIL  {rule}: got {got}");
        }
    }
    println!("{passed} of {} tests pass", TESTS.lines().count());
}

In Rust: pay() and exit() return a Result, so a refusal is an Err value that the ? operator passes on, and step turns it into the refusal's text with unwrap_or_else.

Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main

TypeScript 6.0 · main.ts

const TESTS = `R4 60 minutes cost 40 | pay 60 | 40
R4 61 minutes cost 80 | pay 61 | 80
R5 paid only once | pay 45; pay 50 | refused: paid, not active
R6 no exit unpaid | exit | refused: active, not paid
R6 exit when paid | pay 30; exit | exited`;

type State = "active" | "paid" | "exited";

class Refused extends Error {}

class Ticket {
  readonly enteredAt: number;
  #state: State = "active";

  constructor(enteredAt: number) {
    this.enteredAt = enteredAt;
  }

  pay(at: number): number {
    this.#move("active", "paid");
    return Math.max(1, Math.floor((at - this.enteredAt + 59) / 60)) * 40; // rupees an hour
  }

  exit(): void {
    this.#move("paid", "exited");
  }

  #move(before: State, after: State): void {
    if (this.#state !== before) {
      throw new Refused(`${this.#state}, not ${before}`);
    }
    this.#state = after;
  }
}

function step(ticket: Ticket, command: string): string {
  try {
    if (command === "exit") {
      ticket.exit();
      return "exited";
    }
    return String(ticket.pay(Number(command.slice("pay ".length))));
  } catch (error) {
    if (!(error instanceof Refused)) throw error;
    return `refused: ${error.message}`;
  }
}

let passed = 0;
const tests = TESTS.split("\n");
for (const test of tests) {
  const [rule, steps, expected] = test.split(" | ");
  const ticket = new Ticket(0); // a fresh ticket for every test
  const got = steps
    .split("; ")
    .map((command) => step(ticket, command))
    .at(-1);
  if (got === expected) {
    passed += 1;
    console.log(`ok    ${rule}`);
  } else {
    console.log(`FAIL  ${rule}: got ${got}`);
  }
}
console.log(`${passed} of ${tests.length} tests pass`);

In TypeScript: State is a union of three string literals, so the compiler refuses a misspelt state such as "payed". There is no enum, because an enum is not erasable syntax.

Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts

Go 1.27 · main.go

package main

import (
	"fmt"
	"strconv"
	"strings"
)

const tests = `R4 60 minutes cost 40 | pay 60 | 40
R4 61 minutes cost 80 | pay 61 | 80
R5 paid only once | pay 45; pay 50 | refused: paid, not active
R6 no exit unpaid | exit | refused: active, not paid
R6 exit when paid | pay 30; exit | exited`

type Ticket struct {
	enteredAt int
	state     string
}

func (t *Ticket) Pay(at int) (int, error) {
	if err := t.move("active", "paid"); err != nil {
		return 0, err
	}
	return max(1, (at-t.enteredAt+59)/60) * 40, nil // rupees an hour
}

func (t *Ticket) Exit() error {
	return t.move("paid", "exited")
}

func (t *Ticket) move(before, after string) error {
	if t.state != before {
		return fmt.Errorf("%s, not %s", t.state, before)
	}
	t.state = after
	return nil
}

func step(ticket *Ticket, command string) string {
	if command == "exit" {
		if err := ticket.Exit(); err != nil {
			return "refused: " + err.Error()
		}
		return "exited"
	}
	at, err := strconv.Atoi(strings.TrimPrefix(command, "pay "))
	if err != nil {
		return "refused: " + err.Error()
	}
	fee, err := ticket.Pay(at)
	if err != nil {
		return "refused: " + err.Error()
	}
	return strconv.Itoa(fee)
}

func main() {
	lines := strings.Split(tests, "\n")
	passed := 0
	for _, test := range lines {
		part := strings.Split(test, " | ")
		ticket := &Ticket{enteredAt: 0, state: "active"} // a fresh ticket for every test
		got := ""
		for _, command := range strings.Split(part[1], "; ") {
			got = step(ticket, command)
		}
		if got == part[2] {
			passed++
			fmt.Println("ok    " + part[0])
		} else {
			fmt.Printf("FAIL  %s: got %s\n", part[0], got)
		}
	}
	fmt.Printf("%d of %d tests pass\n", passed, len(lines))
}

In Go: Pay and Exit return an error as their last result, the way Go functions report failure, and step turns a non-nil error into the refusal's text.

Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go

Swift 6.4 · main.swift

let tests = """
  R4 60 minutes cost 40 | pay 60 | 40
  R4 61 minutes cost 80 | pay 61 | 80
  R5 paid only once | pay 45; pay 50 | refused: paid, not active
  R6 no exit unpaid | exit | refused: active, not paid
  R6 exit when paid | pay 30; exit | exited
  """

struct Refused: Error {
  let reason: String
}

struct Ticket {
  let enteredAt: Int
  private(set) var state = "active"

  mutating func pay(at: Int) throws(Refused) -> Int {
    try move(from: "active", to: "paid")
    return max(1, (at - enteredAt + 59) / 60) * 40  // rupees an hour
  }

  mutating func exit() throws(Refused) {
    try move(from: "paid", to: "exited")
  }

  private mutating func move(from before: String, to after: String) throws(Refused) {
    guard state == before else {
      throw Refused(reason: "\(state), not \(before)")
    }
    state = after
  }
}

func step(_ ticket: inout Ticket, _ command: String) -> String {
  do {
    if command == "exit" {
      try ticket.exit()
      return "exited"
    }
    return String(try ticket.pay(at: Int(command.dropFirst("pay ".count)) ?? 0))
  } catch {
    return "refused: \(error.reason)"
  }
}

extension Substring {
  /// The text without the spaces around it.
  var trimmed: String { String(drop { $0 == " " }.reversed().drop { $0 == " " }.reversed()) }
}

let lines = tests.split(separator: "\n")
var passed = 0
for test in lines {
  let part = test.split(separator: "|").map(\.trimmed)
  var ticket = Ticket(enteredAt: 0)  // a fresh ticket for every test
  var got = ""
  for command in Substring(part[1]).split(separator: ";") {
    got = step(&ticket, command.trimmed)
  }
  if got == part[2] {
    passed += 1
    print("ok    \(part[0])")
  } else {
    print("FAIL  \(part[0]): got \(got)")
  }
}
print("\(passed) of \(lines.count) tests pass")

In Swift: pay and exit use typed throws, throws(Refused), so the catch block knows the error is a Refused and reads its reason without a cast. Ticket is a struct, so step takes it inout.

Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main

Ruby 4.0 · main.rb

# frozen_string_literal: true

TESTS = <<~TXT
  R4 60 minutes cost 40 | pay 60 | 40
  R4 61 minutes cost 80 | pay 61 | 80
  R5 paid only once | pay 45; pay 50 | refused: paid, not active
  R6 no exit unpaid | exit | refused: active, not paid
  R6 exit when paid | pay 30; exit | exited
TXT

# A refusal that a rule of the ticket makes.
class Refused < StandardError; end

# A parking ticket, whose state moves from active to paid to exited.
class Ticket
  def initialize(entered_at)
    @entered_at = entered_at
    @state = 'active'
  end

  def pay(at)
    move('active', 'paid')
    [1, (at - @entered_at + 59) / 60].max * 40 # rupees an hour
  end

  def exit
    move('paid', 'exited')
  end

  private

  def move(before, after)
    raise Refused, "#{@state}, not #{before}" unless @state == before

    @state = after
  end
end

def step(ticket, command)
  return ticket.pay(Integer(command.delete_prefix('pay '))).to_s unless command == 'exit'

  ticket.exit
  'exited'
rescue Refused => e
  "refused: #{e.message}"
end

tests = TESTS.lines(chomp: true)
passed = 0
tests.each do |test|
  rule, steps, expected = test.split(' | ')
  ticket = Ticket.new(0) # a fresh ticket for every test
  got = steps.split('; ').map { |command| step(ticket, command) }.last
  if got == expected
    passed += 1
    puts "ok    #{rule}"
  else
    puts "FAIL  #{rule}: got #{got}"
  end
end
puts "#{passed} of #{tests.size} tests pass"

In Ruby: A refusal is a Refused, a StandardError of the program's own, and the rescue in step catches only that kind. The squiggly heredoc, <<~, removes the table's indentation.

Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb

Kotlin 2.4 · Main.kt

val tests =
    """
    R4 60 minutes cost 40 | pay 60 | 40
    R4 61 minutes cost 80 | pay 61 | 80
    R5 paid only once | pay 45; pay 50 | refused: paid, not active
    R6 no exit unpaid | exit | refused: active, not paid
    R6 exit when paid | pay 30; exit | exited
    """.trimIndent()

class Ticket(
    private val enteredAt: Int,
) {
    private var state = "active"

    fun pay(at: Int): Int {
        move("active", "paid")
        return maxOf(1, (at - enteredAt + 59) / 60) * 40 // rupees an hour
    }

    fun exit() = move("paid", "exited")

    private fun move(
        before: String,
        after: String,
    ) {
        check(state == before) { "$state, not $before" }
        state = after
    }
}

fun step(
    ticket: Ticket,
    command: String,
): String =
    try {
        if (command == "exit") {
            ticket.exit()
            "exited"
        } else {
            ticket.pay(command.removePrefix("pay ").toInt()).toString()
        }
    } catch (refusal: IllegalStateException) {
        "refused: ${refusal.message}"
    }

fun main() {
    val lines = tests.lines()
    var passed = 0
    for (test in lines) {
        val (rule, steps, expected) = test.split(" | ")
        val ticket = Ticket(0) // a fresh ticket for every test
        val got = steps.split("; ").map { step(ticket, it) }.last()
        if (got == expected) {
            passed++
            println("ok    $rule")
        } else {
            println("FAIL  $rule: got $got")
        }
    }
    println("$passed of ${lines.size} tests pass")
}

In Kotlin: check(condition) { message } throws an IllegalStateException with that message, the standard library's way to refuse a call made in the wrong state.

Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar

Dart 3.13 · main.dart

const tests = '''
R4 60 minutes cost 40 | pay 60 | 40
R4 61 minutes cost 80 | pay 61 | 80
R5 paid only once | pay 45; pay 50 | refused: paid, not active
R6 no exit unpaid | exit | refused: active, not paid
R6 exit when paid | pay 30; exit | exited''';

class Ticket {
  Ticket(this.enteredAt);

  final int enteredAt;
  String _state = 'active';

  int pay(int at) {
    _move('active', 'paid');
    final hours = (at - enteredAt + 59) ~/ 60;
    return (hours < 1 ? 1 : hours) * 40; // rupees an hour
  }

  void exit() => _move('paid', 'exited');

  void _move(String before, String after) {
    if (_state != before) {
      throw StateError('$_state, not $before');
    }
    _state = after;
  }
}

String step(Ticket ticket, String command) {
  try {
    if (command == 'exit') {
      ticket.exit();
      return 'exited';
    }
    return '${ticket.pay(int.parse(command.substring('pay '.length)))}';
  } on StateError catch (refusal) {
    return 'refused: ${refusal.message}';
  }
}

void main() {
  final lines = tests.split('\n');
  var passed = 0;
  for (final test in lines) {
    final [rule, steps, expected] = test.split(' | ');
    final ticket = Ticket(0); // a fresh ticket for every test
    var got = '';
    for (final command in steps.split('; ')) {
      got = step(ticket, command);
    }
    if (got == expected) {
      passed++;
      print('ok    $rule');
    } else {
      print('FAIL  $rule: got $got');
    }
  }
  print('$passed of ${lines.length} tests pass');
}

In Dart: A refusal is a StateError. The steps run in a for loop, because map(...).last would call the function for the last step only, since map is lazy and last asks for the last element alone.

Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart

What every language printed

ok    R4 60 minutes cost 40
ok    R4 61 minutes cost 80
ok    R5 paid only once
ok    R6 no exit unpaid
ok    R6 exit when paid
5 of 5 tests pass

Every test passed in every language, although the languages report a refusal in two different ways:

A refusal is Languages How the program turns it into the test’s text
An exception Python, JavaScript, TypeScript, Java, C#, PHP, Swift, Ruby, Kotlin, Dart step() catches the program’s own kind of refusal and nothing else
A value Go, Rust, C++, C step() reads the error value: an error, an Err, a std::unexpected or a false with a message

Java, C#, Kotlin and Dart need no exception class of their own: each standard library has one for a call made in the wrong state, IllegalStateException, InvalidOperationException and StateError. A design that has to work in both styles makes each refusal a named case, never a bare string, so that a test can check which rule refused.

Two of the tabs show a trap of their own. C# and Dart evaluate a mapped sequence lazily, so asking a mapped list of steps for its last outcome would run only the last step and skip the arrange steps before it; their programs run the steps in a loop instead. In real projects the tests also run on each language’s own framework, such as unittest, the node:test runner of Node.js, JUnit 6.1.3 for Java, which needs Java 17 or later, Go’s testing package and the #[test] functions of Rust, but the rules and the tests stay the ones in the table.

Checkpoint

Check: Tests that give the same answer every time · A refusal as an exception or as a value

  1. Question 1 of 3 SpreadLot picks a random free spot. What makes its tests repeatable while production stays random?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: A generator passed to the constructor, random.Random(seed) in tests and the random module's in production

    With the generator as a parameter, every seed gives the same result on every run: 189 seeds always passed, 11 always failed, and seed 26 left spot 3 unused every time, so a failure can be replayed.

  2. Question 2 of 3 Each of 20 visits picks one of 5 spots at random. In what percentage of runs is one given spot never used? Give the answer to two decimal places.

    Type a number.

    A hint

    Each visit misses the spot with chance 4/5.

    Show the answer: Question 2 of 3

    Answer: 1.15 % (anything from 1.13 to 1.17 counts)

    (4/5)^20 = 0.0115, so the spot is skipped in about 1.15 % of runs. The binomial figure shows the other side: with at least one success, P(X ≥ 1) = 0.9885.

  3. Question 3 of 3 In the Go tab, what does Pay return when a ticket is paid a second time?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: Zero and an error saying the ticket is paid, not active

    The test is the same line in every language: pay 45; pay 50 must answer refused, the ticket is paid, not active. Only the way the refusal travels differs, a value in Go, Rust, C++ and C, an exception in the others.

Practise: tests for a library’s six rules

A library service lends books under six rules, L1 to L6. Write its unit tests in Python, one or more per rule, named after it. The sample tests run your tests twice over: against the correct service, where every test must pass, and against four versions that each break one rule, where at least one of your tests must fail.

Exercise · Easy · Python

Write the unit tests of a library service

A small library service lends books to its members. These are its rules:

  • L1 A member can borrow an available book. borrow(member, book, day) returns the day the loan is due: 14 days later.
  • L2 A book that is on loan cannot be borrowed by anyone.
  • L3 A member may hold at most 3 books at a time; a fourth borrow is refused.
  • L4 return_book(book, day) ends the loan: the book is available again, and it no longer counts towards the member's 3.
  • L5 A return after the due day costs 5 rupees for each day late; on the due day or before it, the fine is 0. return_book returns the fine.
  • L6 Returning a book that is not on loan is refused.

A refusal raises LibraryError. Two more methods let a test see the state without reaching inside: is_available(book) and loans_of(member), the sorted list of the books a member holds. Days are plain numbers, so a test chooses every date itself.

In library_tests.py, write unit tests for L2 to L6 in the LibraryTests class: at least one test for each rule, named test_l<number>_…, with a fresh library in each test. The L1 test is done for you. Test only through the public methods above.

The sample tests are the checks: they run your tests against the correct service, where every test must pass, and against four versions that each break one rule. Each of those must make at least one of your tests fail. The service and its four versions are in test_library_checks.py; read them only after your own tests are written.

Starter code · library_tests.py

"""Tests for the library service, one or more for each requirement, L1 to L6, each named after it.

The checks run these tests against the correct library and against four versions that each have one bug.
"""

import unittest

from library import Library, LibraryError

BOOKS = ["B1", "B2", "B3", "B4"]
MEMBERS = ["asha", "ravi"]


class LibraryTests(unittest.TestCase):
    def setUp(self):
        self.library = Library(BOOKS, MEMBERS)  # a new library for every test

    def test_l1_a_loan_is_due_fourteen_days_later(self):
        """L1: a book borrowed on day 3 is due on day 17 and is no longer available"""
        due = self.library.borrow("asha", "B1", day=3)

        self.assertEqual(due, 17)
        self.assertFalse(self.library.is_available("B1"))

    # Write the tests for L2 to L6 here: test_l2_..., test_l3_... and so on.
The sample tests · test_library_checks.py
"""The checks of the exercise: they run your tests (library_tests.py) against the correct library service and against
four versions of it that each have one bug.

Every one of your tests must pass on the correct library, and each buggy library must make at least one of them fail.
The service is defined here, and the checks make it importable as the module `library`, so your file can say
`from library import Library, LibraryError`.
"""

import sys
import types
import unittest


class LibraryError(Exception):
    """A request that the rules refuse."""


class Library:
    LOAN_DAYS = 14  # L1
    MAX_LOANS = 3  # L3
    FINE_PER_DAY = 5  # L5, in rupees

    def __init__(self, books, members):
        self._books = set(books)
        self._loans = {member: [] for member in members}  # member -> the books they hold
        self._due = {}  # book on loan -> (member, due day)

    def borrow(self, member, book, day):
        """Lends the book and returns the day it is due."""
        if member not in self._loans or book not in self._books:
            raise LibraryError(f"unknown member or book: {member}, {book}")
        if book in self._due:
            raise LibraryError(f"{book} is on loan")  # L2
        if len(self._loans[member]) >= self.MAX_LOANS:
            raise LibraryError(f"{member} already holds {self.MAX_LOANS} books")  # L3
        due = day + self.LOAN_DAYS
        self._loans[member].append(book)
        self._due[book] = (member, due)
        return due

    def return_book(self, book, day):
        """Ends the loan and returns the fine."""
        if book not in self._due:
            raise LibraryError(f"{book} is not on loan")  # L6
        member, due = self._due[book]
        self._end_loan(member, book)
        return self._fine(due, day)

    def is_available(self, book):
        return book in self._books and book not in self._due

    def loans_of(self, member):
        return sorted(self._loans[member])

    def _end_loan(self, member, book):
        del self._due[book]
        self._loans[member].remove(book)  # L4

    def _fine(self, due, day):
        late = day - due
        return late * self.FINE_PER_DAY if late > 0 else 0  # L5


class FourthBookAllowed(Library):
    """Bug: a member may hold four books."""

    MAX_LOANS = 4


class FineOnTheDueDay(Library):
    """Bug: the due day itself counts as a day late, so a return on the due day costs 5."""

    def _fine(self, due, day):
        late = day - due
        return max(1, late) * self.FINE_PER_DAY if late >= 0 else 0


class AnyBookReturned(Library):
    """Bug: returning a book that is not on loan is accepted, with no fine."""

    def return_book(self, book, day):
        if book not in self._due:
            return 0
        return super().return_book(book, day)


class LoanStaysWithMember(Library):
    """Bug: a returned book is free again, but it still counts towards the member's three."""

    def _end_loan(self, member, book):
        del self._due[book]


VERSIONS = [Library, FourthBookAllowed, FineOnTheDueDay, AnyBookReturned, LoanStaysWithMember]

library = types.ModuleType("library")
library.Library, library.LibraryError = Library, LibraryError
sys.modules["library"] = library

import library_tests  # noqa: E402 (your file: it imports the module made above)


def your_tests():
    """Every test of library_tests.py: the test methods of its TestCase classes, and plain test_ functions."""
    tests = []
    pending = [unittest.defaultTestLoader.loadTestsFromModule(library_tests)]
    while pending:
        for item in pending.pop():
            if isinstance(item, unittest.TestSuite):
                pending.append(item)
            else:
                tests.append(item)
    for name, value in vars(library_tests).items():
        if name.startswith("test") and callable(value) and not isinstance(value, type):
            tests.append(unittest.FunctionTestCase(value))
    return tests


def name_of(test):
    return test.id().rsplit(".", 1)[-1]


def failing_on(version):
    """Runs your tests with `version` in place of Library; returns the names of those that do not pass."""
    for name, value in list(vars(library_tests).items()):
        if any(value is v for v in VERSIONS):
            setattr(library_tests, name, version)
    library.Library = version
    result = unittest.TestResult()
    unittest.TestSuite(your_tests()).run(result)
    return sorted(name_of(test) for test, _ in result.failures + result.errors)


def caught(version):
    """True when your tests all pass on the correct library and at least one fails on `version`."""
    return bool(your_tests()) and not failing_on(Library) and bool(failing_on(version))


def test_every_requirement_has_a_test():
    """every requirement, L1 to L6, has a test whose name starts with test_l1_, test_l2_ and so on"""
    names = [name_of(test) for test in your_tests()]
    missing = [f"L{n}" for n in range(1, 7) if not any(name.startswith(f"test_l{n}_") for name in names)]
    assert missing == [], f"no test is named after {', '.join(missing)}"


def test_all_pass_on_the_correct_library():
    """every one of your tests passes on the correct library"""
    assert your_tests(), "library_tests.py has no tests"
    failing = failing_on(Library)
    assert failing == [], f"these fail on the correct library: {', '.join(failing)}"


def test_catches_a_fourth_book():
    """a test fails when a member may borrow a fourth book"""
    assert caught(FourthBookAllowed), "no test fails when a member may borrow a fourth book (L3)"


def test_catches_a_fine_on_the_due_day():
    """a test fails when a book returned on its due day is charged"""
    assert caught(FineOnTheDueDay), "no test fails when a return on the due day is charged (L5)"


def test_catches_a_return_of_a_book_not_on_loan():
    """a test fails when returning a book that is not on loan is accepted"""
    assert caught(AnyBookReturned), "no test fails when a book that is not on loan is returned (L6)"


def test_catches_a_loan_that_stays_with_the_member():
    """a test fails when a returned book still counts towards the member's three"""
    assert caught(LoanStaysWithMember), "no test fails when a returned book still counts towards the three (L4)"
A hint

Test each rule on both sides of its boundary: for L5, return one book on its due day and another a day after its due day; for L3, let one member borrow three books, then try a fourth. For L4, check both halves of the rule after a return: another member can borrow the book, and loans_of shows that the first member holds one book fewer.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

The questions below cover all six ideas of the lesson, starting with the one you know least:

Practice that adapts to you

Practise this lesson

12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 Which of these are properties of a test that checks a design?

    Choose every answer that is right.

    Show the answer: Question 1 of 12

    Answer:

    • It calls only public methods
    • Its name says which rule it protects
    • It gives the same answer on every run

    Kent Beck calls the three properties behavioural, structure-insensitive and deterministic, and Software Engineering at Google gives the same advice: test via public APIs, test behaviours, and name tests after them.

  2. Question 2 of 12 A test called test_pay_2 fails in CI. What is wrong with the name?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: It says which method ran, not which rule broke, so the report gives no clue where to look

    Naming a test after the requirement it protects makes the failure report readable before anyone opens the code.

  3. Question 3 of 12 In a CI report, test_r5_ticket_is_paid_once has failed. What does its name tell you before you open any code?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: That rule R5 broke, so a second payment was accepted or refused differently

    Rule R5, a ticket is paid once, no longer holds. The order of tests and the kind of failure are details you look up afterwards.

  4. Question 4 of 12 Under R4, a car enters at minute 0 and pays at minute 121. What fee does a correct lot return?

    Type a number.

    A hint

    Count started hours, 40 rupees each.

    Show the answer: Question 4 of 12

    Answer: 120 rupees

    121 minutes start a third hour, so the car pays 3 × 40 = 120 rupees. The suite's own R4 test checks 60 and 61 minutes, one hour and two, the two sides of the boundary.

  5. Question 5 of 12 How many of the nine planted bugs did the two happy-path tests catch?

    Type a number.

    Show the answer: Question 5 of 12

    Answer: 2 bugs

    They caught the wrong spot and the hours rounded down. The eight rule tests caught 8, and the second-visit test the ninth.

  6. Question 6 of 12 One planted bug survived all eight requirement tests: after leaving, a car could never come back. Why did no test catch it?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: No test let a plate leave and then enter again, the other side of rule R3

    A ninth test, test_r3_plate_may_enter_again_after_leaving, catches it. Test both sides of a rule.

  7. Question 7 of 12 What is a refactoring, in Martin Fowler's sense?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: A change to how code is built that does not change what it does

    The heap version of the lot is a refactoring: it behaves the same, so the rule tests passed, and only the tests of its old structure broke.

  8. Question 8 of 12 The lot is refactored so that each kind of spot keeps a heap of its free spots, and it behaves exactly as before. Which of these assertions, written before the refactoring, now stops working?

    Read the code, then choose one answer.

    ticket = lot.enter("KA01AB1234", "car", at=0)
    self.assertEqual(ticket.spot, 2)                    # A
    self.assertEqual(lot.free_spots("car"), 1)          # B
    self.assertEqual(lot._holder, {2: "KA01AB1234"})    # C
    Show the answer: Question 8 of 12

    Answer: C, because it reads a private dictionary that the refactoring removed

    A and B use only public methods and check what the rules promise, so they pass before and after. C checks how the lot stores its data, although nothing a driver could notice has changed.

  9. Question 9 of 12 Which of these make a test give the same result on every run?

    Choose every answer that is right.

    Show the answer: Question 9 of 12

    Answer:

    • Sorting the items of a set before comparing them with a list
    • Passing the time into the method as an argument
    • Passing a seeded random.Random into the class under test

    Time passed in, a generator the test seeds and sorted output remove the three usual sources of change between runs.

  10. Question 10 of 12 A lot picks one of its 5 spots at random for every car. A test sends 20 cars in one after another, each leaving before the next arrives, and asserts that every spot was used. In what share of runs does it fail, to the nearest whole percent?

    Type a number.

    Show the answer: Question 10 of 12

    Answer: 6 % (anything from 5 to 7 counts)

    One given spot is skipped by all 20 cars with probability (4/5)^20, about 1.15 %. Any of the 5 spots can be the one left out, so the test fails in about 5.7 % of runs. The lesson's 200 seeded runs failed 11 times, 5.5 %.

  11. Question 11 of 12 In the block of 14 languages, which languages return a refusal as a value instead of throwing it?

    Choose every answer that is right.

    Show the answer: Question 11 of 12

    Answer:

    • Go
    • C++
    • Rust

    Go, Rust, C++ and C return refusals as values; the other ten throw. The tests and their expected outcomes are the same lines in every language.

  12. Question 12 of 12 In C#, the steps of a test were mapped with Select and the last outcome read with Last(). R5's test then failed with got 40. Why?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Select is lazy, so asking for the last outcome ran only the last step and skipped the first payment

    A test's arrange steps must all run. The C# and Dart programs run the steps in a loop, so every step happens before the last outcome is compared.

Interview questions

Fresher · What is arrange, act, assert, and why should a unit test have only one act?

It is the shape of a focused unit test. Arrange builds the objects in the state the rule needs, such as a lot with one car that has paid. Act makes the one call the rule is about, such as lot.exit(ticket.id). Assert checks what the rule promises through the public methods, such as free_spots(“car”) == 2. With a single act, a failing test points at one behaviour, and its name can say which. A test with several acts checks several behaviours: its name cannot describe them all, and its first failure hides whatever the later acts would have shown. Several assertions after one act are fine when they describe one outcome, such as a due day and the book’s new state after a loan. When the outcome is a refusal, act and assert merge into one statement, as with assertRaises.

A good interviewer keeps going after that answer. For each follow-up below, pick the reply you would give; the feedback explains what it gets right or misses:

Checkpoint

Interview round: three follow-up questions

  1. Question 1 of 3 Interview follow-up: your demo shows a car entering, paying and leaving, and it works. Why is that not enough to show the design is right?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: The demo never asks the lot to refuse anything, and most rules are refusals and state changes

    Five of the eight rules are refusals. A demo exercises the path you already watched work; the tests named after the refusals exercise the paths nobody watched.

  2. Question 2 of 3 Interview follow-up: how do you show that your tests do not depend on the order in which they run?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: Each test arranges its own fresh object, and the suite passes when run in another order or one test at a time

    setUp() builds a new lot for every test, so no test sees another's cars. Running one test alone, or in another order, is the quick way to show it.

  3. Question 3 of 3 Interview follow-up: in Go your lot returns errors, in Java it throws. How do the tests of R5 differ?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: Only in how they read the refusal; the rule, the steps and the expected outcome are the same

    Make each refusal a named case, an error value or an exception type with its message, so that a test in any language can check which rule refused.

Three harder questions build on the same rules: tests that read private state, a flaky test, and the last minutes of a coding round:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    Your parking-lot tests read lot._holder and call lot._lowest_free() directly. The interviewer says they test the implementation, not the design. What is wrong with them, and how would you rewrite them?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A parking-lot test fails about once in twenty CI runs and passes when someone re-runs it. The lot gives each car a free spot chosen at random. How do you find the cause, and how do you fix the test so that it is repeatable and still means something?

    Model answer, Pro. First step and follow-ups

  3. Fresher to mid

    Fifteen minutes remain in a machine-coding round. Your library system works in the demo, has six numbered rules and no tests. Which tests do you write, in what order, and what do you say about the rest?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A test of a design names the rule it protects, calls only public methods and gives the same answer on every run.
  • Each test arranges a fresh object, makes one call and asserts what the rule promises, and a refusal merges act and assert into one statement.
  • The tests of refusals, boundaries and state changes come first: two happy-path tests caught 2 of 9 planted bugs, and the eight rule tests caught 8.
  • Tests of private details broke on a refactoring and caught nothing extra, while the rule tests passed before and after it.
  • Times passed in, seeded generators, sorted output and rules that hold for every seed make a test repeatable; a test that depended on chance failed 11 times in 200.
  • The same tests work in every language, and only the way a refusal is reported changes, an exception or an error value.

Terms in this lesson

arrange, act, assert
The shape of a focused unit test: arrange builds the state a rule needs, act makes the one call the rule is about, and assert checks what the rule promises.
flaky test
A test that passes and fails without any change to the code, usually because it depends on the clock, on chance, on an order nothing promises or on state another test left behind.
mutation testing
Measuring a test suite by planting small faults in a copy of the code, one at a time, and counting the faults that make at least one test fail.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.