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.
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:
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.
- 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.
- 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.
- 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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- Arrange, the entry. The test makes the state the rule needs. A car enters at minute 0 and gets ticket T1 for spot 2.
- 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.
- 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.
- The lot refuses. The lot raises LotError with the message T1 cannot be paid, it is paid, and leaves the ticket as it was.
- 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:
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) Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Two more conventions carry the suite:
- A fresh lot for every test.
unittestcallssetUp()before each test method, so no test sees the cars of another, and the tests pass in any order. (unittestruns 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
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:
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) Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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:
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 Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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
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
randommodule’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:
In 20 tries with p = 0.2, the chance of at least 1 successes is 0.9885; on average there are 4.
The numbers as a table
| Successes | Probability |
|---|---|
| 0 | 0.0115 |
| 1 | 0.0576 |
| 2 | 0.1369 |
| 3 | 0.2054 |
| 4 | 0.2182 |
| 5 | 0.1746 |
| 6 | 0.1091 |
| 7 | 0.0545 |
| 8 | 0.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:
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() Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The test failed in 11 of 200 runs, 5.5 %, close to the 5.7 % worked out above. The fix has two steps:
- Make it repeatable. The generator becomes a constructor parameter,
rng, which defaults to therandommodule’s shared generator in production; a test passesrandom.Random(seed)instead. Python’s documentation explains why that works: the module’s functions are bound methods of one hiddenRandominstance, 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. - 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:
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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 48 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 154 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 3 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 9 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 26 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 730 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 191 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 15 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
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
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
borrowis 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_bookreturns 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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
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.
-
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?
-
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?
-
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?
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
- unittest: Unit testing framework (Python Software Foundation)
- random: Generate pseudo-random numbers (Notes on Reproducibility) (Python Software Foundation)
- Data model: object.__hash__ (hash randomization) (Python Software Foundation)
- Test runner (node:test), Node.js v24 documentation (OpenJS Foundation (Node.js project))
- JUnit User Guide 6.1.3: Overview (supported Java versions) (The JUnit Team)
- Package testing (The Go Project)
- The Go Programming Language Specification: For statements with range clause (The Go Project)
- The Rust Programming Language: How to Write Tests (The Rust Project)
- The Rust Programming Language: Test Organization (The Rust Project)
- The Rust Programming Language: Recoverable Errors with Result (The Rust Project)
- The Java Language Specification, Java SE 25: Access Control (6.6) (Oracle)
- Class IllegalStateException (Java SE 25) (Oracle)
- InvalidOperationException Class (Microsoft)
- Deferred execution and lazy evaluation (LINQ) (Microsoft)
- std::expected (cppreference.com)
- check (kotlin) (JetBrains (Kotlin))
- StateError class (dart:core) (Dart (Google))
- Iterable.map (dart:core) (Dart (Google))
- The Swift Programming Language: error handling (typed throws) (Swift.org)
- Strings: nowdoc syntax (The PHP Group)
- 3A: Arrange, Act, Assert (Bill Wake (XP123))
- Test Desiderata (Kent Beck)
- Software Engineering at Google, chapter 12: Unit Testing (O’Reilly Media (published online by the Abseil project))
- PIT: What is mutation testing? (PIT mutation testing project)
- Definition Of Refactoring (Martin Fowler)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress