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 2 – Designing objects for change

From a script to an object model

Find the change points in a billing script, then refactor it into MenuItem, Order and Bill classes in five steps, with a golden-output check after each one.

  • Beginner
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot

What you will learn

  • Identify the change points in a procedural script that justify objects
  • Refactor a script into classes in small steps without changing its output
  • Compare how easily a script and an object model can be tested

Before you start

On this page

A script that works is not a problem. A script that keeps having to change is, because every change starts with a hunt for all the lines it touches. In a low-level design (LLD) round, and in real code, the usual answer is an object model: classes that each own some data and the rules that guard it. This lesson takes a 60-line canteen billing script, finds where likely changes would land, and refactors it into MenuItem, Order and Bill classes in five small steps, checking after every step that the program prints exactly what it printed before.

You need to be able to write a Python class with __init__ and methods; the Object-Oriented Programming track teaches that syntax, and this track builds on it.

When a script is enough

A class keeps data and the code that works on that data in one place, which is how the Python tutorial introduces classes. That earns its keep when the data and the rules that guard it change together, and when you want to check one rule without running the whole program. A short script that runs a few times and is then thrown away rarely needs either: its whole life is one path through the code, and reading it from top to bottom is the simplest way to understand it.

Three signs that a script has outgrown that stage:

  • Several functions read and change the same global variables.
  • One change request means edits in several distant places, or two unrelated requests edit the same function.
  • You want to test one rule, such as a discount, and the only way in is the whole program.

The script

An office canteen runs this script at its till. Each line of the input is a menu code and a quantity, and a blank line ends an order. The script prints a bill for every order, then the day’s takings:

canteen_script.py, the starting point Python · canteen/canteen_script.py
"""Canteen billing, written as a quick script.

Reads orders from standard input, one item per line ("TEA 2") and a blank line
between orders, then prints a bill for each order and the day's takings.
Prices are whole numbers of paise (100 paise make one rupee).
"""
import sys

MENU = {
    "TEA": ("Masala tea", 1500),
    "COF": ("Filter coffee", 2000),
    "SAM": ("Samosa", 1800),
    "DOS": ("Masala dosa", 6000),
    "THA": ("Veg thali", 12000),
}

bill_no = 0
day_total = 0
lines = []


def money(paise):
    return f"{paise // 100}.{paise % 100:02d}"


def print_bill():
    global bill_no, day_total
    bill_no += 1
    print(f"Bill {bill_no}")
    subtotal = 0
    for name, price, qty in lines:
        subtotal += price * qty
        print(f"  {name:<14}{qty:>3} x {money(price):>6}{money(price * qty):>9}")
    print(f"  {'Subtotal':<26}{money(subtotal):>9}")
    discount = 0
    if subtotal >= 50000:
        discount = subtotal // 10
        print(f"  {'Discount 10%':<26}{'-' + money(discount):>9}")
    tax = ((subtotal - discount) * 5 + 50) // 100
    print(f"  {'Tax 5%':<26}{money(tax):>9}")
    total = subtotal - discount + tax
    print(f"  {'Total':<26}{money(total):>9}")
    print()
    day_total += total
    lines.clear()


for row in sys.stdin:
    row = row.strip()
    if not row:
        if lines:
            print_bill()
        continue
    code, qty = row.split()
    name, price = MENU[code]
    lines.append((name, price, int(qty)))
if lines:
    print_bill()
print(f"Day: {bill_no} bills, {money(day_total)} taken")

Input (standard input)

TEA 2
SAM 3
TEA 1

THA 4
COF 2
DOS 1

Output

Bill 1
  Masala tea      2 x  15.00    30.00
  Samosa          3 x  18.00    54.00
  Masala tea      1 x  15.00    15.00
  Subtotal                      99.00
  Tax 5%                         4.95
  Total                        103.95

Bill 2
  Veg thali       4 x 120.00   480.00
  Filter coffee   2 x  20.00    40.00
  Masala dosa     1 x  60.00    60.00
  Subtotal                     580.00
  Discount 10%                 -58.00
  Tax 5%                        26.10
  Total                        548.10

Day: 2 bills, 652.05 taken

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

Prices are whole numbers of paise, the hundredth part of a rupee, so every amount is an exact integer. A binary float cannot hold most decimal fractions exactly (the Python tutorial’s chapter on floating-point arithmetic shows why), and a bill has to add up to the last paisa. money() turns paise into text such as 103.95.

Now look at what print_bill() does: it numbers the bill, works out every amount, prints every line, adds the total to the day’s takings and empties the list of lines. That is five jobs, tied together by three global variables (bill_no, day_total and lines), and the loop at the bottom of the file changes lines too.

Find the change points

A change point is a place you would have to edit for a change that is likely to be asked for. Write down a few likely requests and find where each one lands:

A likely request Where it lands in the script
A new dish, or a new price The MENU dictionary, and nothing else
Every quantity must be at least 1 The loop at the bottom, beside lines.append(...)
15% off from 400.00, instead of 10% from 500.00 Three places in print_bill(): the if, subtotal // 10 and the label "Discount 10%"
The bill as JSON for a self-service kiosk Every print() in print_bill(), mixed in with the calculations
Orders as CSV instead of lines of text The loop at the bottom, which also fills the global lines
A test of the tax rounding on its own Nowhere: the only way in is a whole order on standard input

The first request already has a home: the menu is data, kept apart from the code, so it needs no class. The next four land in print_bill() or in the loop, the two pieces of code that change the globals, and a request about money (the discount) lands in the same function as a request about looks (the JSON). The last one has nowhere to land at all. That is the signal that objects answer. Robert C. Martin gives his single responsibility principle this wording:

Gather together the things that change for the same reasons. Separate those things that change for different reasons.

He traces the idea back to David Parnas, who argued in 1972 that a program should be divided by the design decisions that are likely to change, each one hidden inside a module of its own. The steps below do that on a small scale; a later module of this track covers the principle itself.

Record the golden output first

Martin Fowler defines a refactoring as

a change made to the internal structure of software to make it easier to understand and cheaper to modify without changing its observable behavior.

For a program that reads input and prints text, its observable behaviour is what it prints, so the safety net is simple. Run the original on a few inputs and keep what it prints as the golden output. After every step, run the new version on the same inputs and compare: any difference means the step changed behaviour.

Choose the inputs so that every rule and every edge runs at least once:

  • two orders: the sample above, a small order without a discount and a large one with it;
  • no orders: an empty input, which still prints the day’s line;
  • exactly 500.00: the boundary where the discount starts;
  • unknown code: XYZ is not on the menu, so the script stops with a KeyError, and every step must stop the same way.

Five small steps

Each step changes one thing, and the comparison runs after each one. When it reports a difference, find the mistake or undo the step before you go on.

Step 1: stop changing global variables

Start with the global variables, because they are what makes a script hard to test. A common half-way stage is to cut the code into functions but leave the data in a global list. That looks fine until two pieces of code use the functions one after the other in the same program, as two tests in one test run do:

Functions that still share a global list Python · canteen/shared_lines.py
"""A half-done refactoring: functions exist, but the lines still live in a global."""
lines = []  # one list, shared by every caller in this program


def add(name, price, quantity):
    lines.append((name, price * quantity))


def subtotal():
    return sum(amount for name, amount in lines)


# Two independent checks, the way two tests would make them:
add("Masala tea", 1500, 2)
print("first order:", subtotal())
add("Samosa", 1800, 1)
print("second order:", subtotal())  # meant to be 1800

Output

first order: 3000
second order: 4800

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

The second order should come to 1800 paise, but the list still holds the first order’s tea. Python runs a module’s top-level code only the first time the module is imported, so all the tests of one run share that one list, and a test can pass on its own yet fail after another test. Step 1 therefore moves the loop into a main() function, which owns the bill number, the day’s total and the lines, and print_bill() gets what it needs as arguments and returns the bill’s total. MENU stays at module level: nothing writes to it, so sharing it does no harm. The if __name__ == "__main__": test at the end calls main() only when the file runs as a program, so a test can import the file without a bill being printed.

Steps 2 to 5: the input, then one class at a time

  1. Read the input separately. read_orders(rows) turns lines of text into orders, each a list of (code, quantity) pairs. It takes anything that yields lines of text, such as a list or an open file, so a test can pass a list instead of standard input.
  2. Make menu items values. A MenuItem dataclass with frozen=True replaces the (name, price) tuple. Code says item.price instead of remembering which position holds the price, assigning to a field raises an exception, and two items with the same fields are equal, as two equal numbers are. Menu.find(code) becomes the one place that looks an item up; it still raises KeyError for an unknown code, so the error does not change.
  3. Give orders a class. Order.add(item, quantity) is the one public way to add a line, and Order.lines hands out a tuple that callers cannot change. Each OrderLine knows its own amount, so price times quantity is written once.
  4. Let a bill work out its amounts. Bill computes the subtotal, discount, tax and total once, from an order, and print_bill(bill) only formats them. The rules are named constants at the top of the file and the labels are built from them, so the printed “10%” can no longer drift away from the rule.

Each tab holds the whole program after that step, with what it printed for the two sample orders:

The five steps

1 Local state · canteen/step1_local_state.py

"""Canteen billing, step 1: no global variable changes any more.

main() owns the bill number, the day's total and the current lines; print_bill()
is given what it needs and returns the bill's total instead of updating globals.
"""
import sys

MENU = {
    "TEA": ("Masala tea", 1500),
    "COF": ("Filter coffee", 2000),
    "SAM": ("Samosa", 1800),
    "DOS": ("Masala dosa", 6000),
    "THA": ("Veg thali", 12000),
}


def money(paise):
    return f"{paise // 100}.{paise % 100:02d}"


def print_bill(bill_no, lines):
    """Prints one bill and returns its total."""
    print(f"Bill {bill_no}")
    subtotal = 0
    for name, price, qty in lines:
        subtotal += price * qty
        print(f"  {name:<14}{qty:>3} x {money(price):>6}{money(price * qty):>9}")
    print(f"  {'Subtotal':<26}{money(subtotal):>9}")
    discount = 0
    if subtotal >= 50000:
        discount = subtotal // 10
        print(f"  {'Discount 10%':<26}{'-' + money(discount):>9}")
    tax = ((subtotal - discount) * 5 + 50) // 100
    print(f"  {'Tax 5%':<26}{money(tax):>9}")
    total = subtotal - discount + tax
    print(f"  {'Total':<26}{money(total):>9}")
    print()
    return total


def main():
    bill_no = 0
    day_total = 0
    lines = []
    for row in sys.stdin:
        row = row.strip()
        if not row:
            if lines:
                bill_no += 1
                day_total += print_bill(bill_no, lines)
                lines = []
            continue
        code, qty = row.split()
        name, price = MENU[code]
        lines.append((name, price, int(qty)))
    if lines:
        bill_no += 1
        day_total += print_bill(bill_no, lines)
    print(f"Day: {bill_no} bills, {money(day_total)} taken")


if __name__ == "__main__":
    main()

Input (standard input)

TEA 2
SAM 3
TEA 1

THA 4
COF 2
DOS 1

Output

Bill 1
  Masala tea      2 x  15.00    30.00
  Samosa          3 x  18.00    54.00
  Masala tea      1 x  15.00    15.00
  Subtotal                      99.00
  Tax 5%                         4.95
  Total                        103.95

Bill 2
  Veg thali       4 x 120.00   480.00
  Filter coffee   2 x  20.00    40.00
  Masala dosa     1 x  60.00    60.00
  Subtotal                     580.00
  Discount 10%                 -58.00
  Tax 5%                        26.10
  Total                        548.10

Day: 2 bills, 652.05 taken

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

2 Input · canteen/step2_read_orders.py

"""Canteen billing, step 2: reading the input is a function of its own.

read_orders() turns lines of text into orders, so the billing code no longer
cares where an order comes from: standard input, a file or a test.
"""
import sys

MENU = {
    "TEA": ("Masala tea", 1500),
    "COF": ("Filter coffee", 2000),
    "SAM": ("Samosa", 1800),
    "DOS": ("Masala dosa", 6000),
    "THA": ("Veg thali", 12000),
}


def money(paise):
    return f"{paise // 100}.{paise % 100:02d}"


def read_orders(rows):
    """Yields each order as a list of (code, quantity); a blank line ends an order."""
    order = []
    for row in rows:
        row = row.strip()
        if row:
            code, qty = row.split()
            order.append((code, int(qty)))
        elif order:
            yield order
            order = []
    if order:
        yield order


def print_bill(bill_no, lines):
    """Prints one bill and returns its total."""
    print(f"Bill {bill_no}")
    subtotal = 0
    for name, price, qty in lines:
        subtotal += price * qty
        print(f"  {name:<14}{qty:>3} x {money(price):>6}{money(price * qty):>9}")
    print(f"  {'Subtotal':<26}{money(subtotal):>9}")
    discount = 0
    if subtotal >= 50000:
        discount = subtotal // 10
        print(f"  {'Discount 10%':<26}{'-' + money(discount):>9}")
    tax = ((subtotal - discount) * 5 + 50) // 100
    print(f"  {'Tax 5%':<26}{money(tax):>9}")
    total = subtotal - discount + tax
    print(f"  {'Total':<26}{money(total):>9}")
    print()
    return total


def main():
    bill_no = 0
    day_total = 0
    for order in read_orders(sys.stdin):
        lines = [(*MENU[code], qty) for code, qty in order]
        bill_no += 1
        day_total += print_bill(bill_no, lines)
    print(f"Day: {bill_no} bills, {money(day_total)} taken")


if __name__ == "__main__":
    main()

Input (standard input)

TEA 2
SAM 3
TEA 1

THA 4
COF 2
DOS 1

Output

Bill 1
  Masala tea      2 x  15.00    30.00
  Samosa          3 x  18.00    54.00
  Masala tea      1 x  15.00    15.00
  Subtotal                      99.00
  Tax 5%                         4.95
  Total                        103.95

Bill 2
  Veg thali       4 x 120.00   480.00
  Filter coffee   2 x  20.00    40.00
  Masala dosa     1 x  60.00    60.00
  Subtotal                     580.00
  Discount 10%                 -58.00
  Tax 5%                        26.10
  Total                        548.10

Day: 2 bills, 652.05 taken

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

3 MenuItem · canteen/step3_menu_item.py

"""Canteen billing, step 3: menu items are values, and the menu finds them.

A MenuItem replaces the (name, price) tuple, so code reads item.price instead of
remembering which position holds the price. Menu.find() is the one place that
looks an item up by its code.
"""
import sys
from dataclasses import dataclass


@dataclass(frozen=True)
class MenuItem:
    code: str
    name: str
    price: int  # paise


class Menu:
    def __init__(self, items):
        self._items = {item.code: item for item in items}

    def find(self, code):
        """The item with this code (a KeyError for an unknown code, as before)."""
        return self._items[code]


MENU = Menu([
    MenuItem("TEA", "Masala tea", 1500),
    MenuItem("COF", "Filter coffee", 2000),
    MenuItem("SAM", "Samosa", 1800),
    MenuItem("DOS", "Masala dosa", 6000),
    MenuItem("THA", "Veg thali", 12000),
])


def money(paise):
    return f"{paise // 100}.{paise % 100:02d}"


def read_orders(rows):
    """Yields each order as a list of (code, quantity); a blank line ends an order."""
    order = []
    for row in rows:
        row = row.strip()
        if row:
            code, qty = row.split()
            order.append((code, int(qty)))
        elif order:
            yield order
            order = []
    if order:
        yield order


def print_bill(bill_no, lines):
    """Prints one bill from (MenuItem, quantity) pairs and returns its total."""
    print(f"Bill {bill_no}")
    subtotal = 0
    for item, qty in lines:
        subtotal += item.price * qty
        print(f"  {item.name:<14}{qty:>3} x "
              f"{money(item.price):>6}{money(item.price * qty):>9}")
    print(f"  {'Subtotal':<26}{money(subtotal):>9}")
    discount = 0
    if subtotal >= 50000:
        discount = subtotal // 10
        print(f"  {'Discount 10%':<26}{'-' + money(discount):>9}")
    tax = ((subtotal - discount) * 5 + 50) // 100
    print(f"  {'Tax 5%':<26}{money(tax):>9}")
    total = subtotal - discount + tax
    print(f"  {'Total':<26}{money(total):>9}")
    print()
    return total


def main():
    bill_no = 0
    day_total = 0
    for order in read_orders(sys.stdin):
        lines = [(MENU.find(code), qty) for code, qty in order]
        bill_no += 1
        day_total += print_bill(bill_no, lines)
    print(f"Day: {bill_no} bills, {money(day_total)} taken")


if __name__ == "__main__":
    main()

Input (standard input)

TEA 2
SAM 3
TEA 1

THA 4
COF 2
DOS 1

Output

Bill 1
  Masala tea      2 x  15.00    30.00
  Samosa          3 x  18.00    54.00
  Masala tea      1 x  15.00    15.00
  Subtotal                      99.00
  Tax 5%                         4.95
  Total                        103.95

Bill 2
  Veg thali       4 x 120.00   480.00
  Filter coffee   2 x  20.00    40.00
  Masala dosa     1 x  60.00    60.00
  Subtotal                     580.00
  Discount 10%                 -58.00
  Tax 5%                        26.10
  Total                        548.10

Day: 2 bills, 652.05 taken

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

4 Order · canteen/step4_order.py

"""Canteen billing, step 4: an Order holds its lines and adds them up.

Callers add items through Order.add() and read the lines back; they cannot
change the list behind the order's back. A line knows its own amount.
"""
import sys
from dataclasses import dataclass


@dataclass(frozen=True)
class MenuItem:
    code: str
    name: str
    price: int  # paise


class Menu:
    def __init__(self, items):
        self._items = {item.code: item for item in items}

    def find(self, code):
        """The item with this code (a KeyError for an unknown code, as before)."""
        return self._items[code]


@dataclass(frozen=True)
class OrderLine:
    item: MenuItem
    quantity: int

    @property
    def amount(self):
        return self.item.price * self.quantity


class Order:
    """The lines of one order, in the order they were added."""

    def __init__(self):
        self._lines = []

    def add(self, item, quantity):
        self._lines.append(OrderLine(item, quantity))

    @property
    def lines(self):
        return tuple(self._lines)

    def subtotal(self):
        return sum(line.amount for line in self._lines)


MENU = Menu([
    MenuItem("TEA", "Masala tea", 1500),
    MenuItem("COF", "Filter coffee", 2000),
    MenuItem("SAM", "Samosa", 1800),
    MenuItem("DOS", "Masala dosa", 6000),
    MenuItem("THA", "Veg thali", 12000),
])


def money(paise):
    return f"{paise // 100}.{paise % 100:02d}"


def read_orders(rows):
    """Yields each order as a list of (code, quantity); a blank line ends an order."""
    order = []
    for row in rows:
        row = row.strip()
        if row:
            code, qty = row.split()
            order.append((code, int(qty)))
        elif order:
            yield order
            order = []
    if order:
        yield order


def print_bill(bill_no, order):
    """Prints one bill and returns its total."""
    print(f"Bill {bill_no}")
    for line in order.lines:
        print(f"  {line.item.name:<14}{line.quantity:>3} x "
              f"{money(line.item.price):>6}{money(line.amount):>9}")
    subtotal = order.subtotal()
    print(f"  {'Subtotal':<26}{money(subtotal):>9}")
    discount = 0
    if subtotal >= 50000:
        discount = subtotal // 10
        print(f"  {'Discount 10%':<26}{'-' + money(discount):>9}")
    tax = ((subtotal - discount) * 5 + 50) // 100
    print(f"  {'Tax 5%':<26}{money(tax):>9}")
    total = subtotal - discount + tax
    print(f"  {'Total':<26}{money(total):>9}")
    print()
    return total


def main():
    bill_no = 0
    day_total = 0
    for codes in read_orders(sys.stdin):
        order = Order()
        for code, qty in codes:
            order.add(MENU.find(code), qty)
        bill_no += 1
        day_total += print_bill(bill_no, order)
    print(f"Day: {bill_no} bills, {money(day_total)} taken")


if __name__ == "__main__":
    main()

Input (standard input)

TEA 2
SAM 3
TEA 1

THA 4
COF 2
DOS 1

Output

Bill 1
  Masala tea      2 x  15.00    30.00
  Samosa          3 x  18.00    54.00
  Masala tea      1 x  15.00    15.00
  Subtotal                      99.00
  Tax 5%                         4.95
  Total                        103.95

Bill 2
  Veg thali       4 x 120.00   480.00
  Filter coffee   2 x  20.00    40.00
  Masala dosa     1 x  60.00    60.00
  Subtotal                     580.00
  Discount 10%                 -58.00
  Tax 5%                        26.10
  Total                        548.10

Day: 2 bills, 652.05 taken

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

5 Bill · canteen/step5_bill.py

"""Canteen billing, step 5: a Bill works out every amount; print_bill() only formats.

The pricing rules are named once, at the top, and live in Bill. Changing how a
bill looks no longer risks changing what it costs, and the other way round.
"""
import sys
from dataclasses import dataclass

DISCOUNT_FROM = 50000  # paise: orders of 500.00 or more get the discount
DISCOUNT_PERCENT = 10
TAX_PERCENT = 5


@dataclass(frozen=True)
class MenuItem:
    code: str
    name: str
    price: int  # paise


class Menu:
    def __init__(self, items):
        self._items = {item.code: item for item in items}

    def find(self, code):
        """The item with this code (a KeyError for an unknown code, as before)."""
        return self._items[code]


@dataclass(frozen=True)
class OrderLine:
    item: MenuItem
    quantity: int

    @property
    def amount(self):
        return self.item.price * self.quantity


class Order:
    """The lines of one order, in the order they were added."""

    def __init__(self):
        self._lines = []

    def add(self, item, quantity):
        self._lines.append(OrderLine(item, quantity))

    @property
    def lines(self):
        return tuple(self._lines)

    def subtotal(self):
        return sum(line.amount for line in self._lines)


class Bill:
    """Every amount of one bill, worked out once from its order."""

    def __init__(self, number, order):
        self.number = number
        self.lines = order.lines
        self.subtotal = order.subtotal()
        self.discount = 0
        if self.subtotal >= DISCOUNT_FROM:
            self.discount = self.subtotal * DISCOUNT_PERCENT // 100
        # Half a paisa or more rounds up.
        self.tax = ((self.subtotal - self.discount) * TAX_PERCENT + 50) // 100
        self.total = self.subtotal - self.discount + self.tax


MENU = Menu([
    MenuItem("TEA", "Masala tea", 1500),
    MenuItem("COF", "Filter coffee", 2000),
    MenuItem("SAM", "Samosa", 1800),
    MenuItem("DOS", "Masala dosa", 6000),
    MenuItem("THA", "Veg thali", 12000),
])


def money(paise):
    return f"{paise // 100}.{paise % 100:02d}"


def read_orders(rows):
    """Yields each order as a list of (code, quantity); a blank line ends an order."""
    order = []
    for row in rows:
        row = row.strip()
        if row:
            code, qty = row.split()
            order.append((code, int(qty)))
        elif order:
            yield order
            order = []
    if order:
        yield order


def print_bill(bill):
    """Prints a bill: what it looks like is decided here, what it costs in Bill."""
    print(f"Bill {bill.number}")
    for line in bill.lines:
        print(f"  {line.item.name:<14}{line.quantity:>3} x "
              f"{money(line.item.price):>6}{money(line.amount):>9}")
    print(f"  {'Subtotal':<26}{money(bill.subtotal):>9}")
    if bill.discount:
        print(f"  {f'Discount {DISCOUNT_PERCENT}%':<26}{'-' + money(bill.discount):>9}")
    print(f"  {f'Tax {TAX_PERCENT}%':<26}{money(bill.tax):>9}")
    print(f"  {'Total':<26}{money(bill.total):>9}")
    print()


def main():
    bills = 0
    day_total = 0
    for codes in read_orders(sys.stdin):
        order = Order()
        for code, qty in codes:
            order.add(MENU.find(code), qty)
        bills += 1
        bill = Bill(bills, order)
        print_bill(bill)
        day_total += bill.total
    print(f"Day: {bills} bills, {money(day_total)} taken")


if __name__ == "__main__":
    main()

Input (standard input)

TEA 2
SAM 3
TEA 1

THA 4
COF 2
DOS 1

Output

Bill 1
  Masala tea      2 x  15.00    30.00
  Samosa          3 x  18.00    54.00
  Masala tea      1 x  15.00    15.00
  Subtotal                      99.00
  Tax 5%                         4.95
  Total                        103.95

Bill 2
  Veg thali       4 x 120.00   480.00
  Filter coffee   2 x  20.00    40.00
  Masala dosa     1 x  60.00    60.00
  Subtotal                     580.00
  Discount 10%                 -58.00
  Tax 5%                        26.10
  Total                        548.10

Day: 2 bills, 652.05 taken

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

Not everything became a class. read_orders(), money() and print_bill() keep nothing between calls, so plain functions suit them. Each class is there because it owns data that has rules: the menu’s items, an order’s lines, a bill’s amounts. The diagram shows where the requests from the table land now.

Five likely change requests, each with an arrow to the one part of the refactored canteen code that it changes.A new dishor a new priceA rule forquantitiesA new discountor tax ruleA new layout,or JSON outputOrders in a newformat, such as CSVMENUthe MenuItem valuesOrderadds linesBillworks out amountsprint_bill()formats a billread_orders()parses the input

Where each likely change lands after the refactoring

Text description of the diagram

The diagram has two columns. On the left are five change requests a canteen could make, drawn as pages; on the right are parts of the step 5 code. Each request has one arrow, to the one part it changes.

  • A new dish or a new price changes the menu's data: the MenuItem values listed in MENU.
  • A rule for quantities, such as "at least 1", goes in Order, which adds every line.
  • A new discount or tax rule changes Bill, which works out every amount.
  • A new layout, or JSON output for a kiosk, changes print_bill(), or adds a second function beside it, without touching the amounts.
  • Orders in a new format, such as CSV, change only read_orders(), which parses the input.

In the original script only the first request had a place of its own, the MENU dictionary. The other four land in print_bill() or in the loop that reads standard input, and both of those change the script's global variables.

Version note

None of this needs a recent Python: frozen dataclasses have been in the standard library since Python 3.7, when the dataclasses module was added.

Check every step

check_steps.py is the comparison. It runs the original script on the four inputs to get the golden output, then runs every step on the same inputs and reports the first line that differs. runpy.run_path() runs a file in a fresh module namespace each time, and its run_name argument sets __name__ to "__main__", so the file runs as a program; contextlib.redirect_stdout() collects what it prints, and sys.stdin is swapped for the input text while it runs:

check_steps.py, the safety net Python · canteen/check_steps.py
"""The safety net: run every step on the same inputs and compare with the script.

The original script's output for each input is the golden output. A step passes
when it prints exactly the same text and stops with the same error, if any.
"""
import contextlib
import io
import runpy
import sys

STEPS = [
    "step1_local_state.py",
    "step2_read_orders.py",
    "step3_menu_item.py",
    "step4_order.py",
    "step5_bill.py",
    "step4_merged.py",
]


def run(program, text):
    """What the program prints for this standard input, and the error it stops with."""
    printed = io.StringIO()
    saved, sys.stdin = sys.stdin, io.StringIO(text)
    error = None
    try:
        with contextlib.redirect_stdout(printed):
            runpy.run_path(program, run_name="__main__")
    except Exception as exc:
        error = f"{type(exc).__name__}: {exc}"
    finally:
        sys.stdin = saved
    return printed.getvalue().splitlines(), error


def first_difference(expected, actual):
    """The first line number where two outputs differ, with both lines."""
    for number, (want, got) in enumerate(zip(expected, actual), start=1):
        if want != got:
            return number, want, got
    number = min(len(expected), len(actual)) + 1
    want = expected[number - 1] if number <= len(expected) else "(end)"
    got = actual[number - 1] if number <= len(actual) else "(end)"
    return number, want, got


def main():
    with open("orders.txt", encoding="utf-8") as f:
        inputs = {
            "two orders": f.read(),
            "no orders": "",
            "exactly 500.00": "THA 4\nCOF 1\n",  # the discount starts here
            "unknown code": "TEA 1\nXYZ 2\n",
        }
    golden = {name: run("canteen_script.py", text) for name, text in inputs.items()}
    print("Golden output of canteen_script.py:")
    for name, (lines, error) in golden.items():
        end = f"stops with {error}" if error else f"ends with {lines[-1]!r}"
        print(f"  {name!r:<17}{end}")
    for step in STEPS:
        for name, text in inputs.items():
            lines, error = run(step, text)
            want_lines, want_error = golden[name]
            if error != want_error:
                print(f"{step:<21} differs for {name!r}: "
                      f"{error} instead of {want_error}")
                break
            if lines != want_lines:
                number, want, got = first_difference(want_lines, lines)
                print(f"{step:<21} differs for {name!r} at line {number}:")
                print(f"  expected {want!r}")
                print(f"  got      {got!r}")
                break
        else:
            print(f"{step:<21} same on all {len(inputs)} inputs")


if __name__ == "__main__":
    main()

Output

Golden output of canteen_script.py:
  'two orders'     ends with 'Day: 2 bills, 652.05 taken'
  'no orders'      ends with 'Day: 0 bills, 0.00 taken'
  'exactly 500.00' ends with 'Day: 1 bills, 472.50 taken'
  'unknown code'   stops with KeyError: 'XYZ'
step1_local_state.py  same on all 4 inputs
step2_read_orders.py  same on all 4 inputs
step3_menu_item.py    same on all 4 inputs
step4_order.py        same on all 4 inputs
step5_bill.py         same on all 4 inputs
step4_merged.py       differs for 'two orders' at line 2:
  expected '  Masala tea      2 x  15.00    30.00'
  got      '  Masala tea      3 x  15.00    45.00'

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

The five steps print the same on all four inputs. The last entry in the report is a step that does not, and it is worth a closer look.

Text Diff When a step prints something else, paste the golden output and the new output side by side to see every changed line, not only the first.

A common mistake: improving while you refactor

step4_merged.py takes step 4 and makes one small improvement, the kind that slips into a refactoring: its order merges a repeated item into the line it already has.

step4_merged.py, a step that also changes behaviour Python · canteen/step4_merged.py
"""Canteen billing, step 4 with a slip: the order merges repeated items.

This file reuses step 4 and changes one thing. Adding an item that is already in
the order adds to that line instead of making a new one. The totals stay the
same, but the printed bill does not, so this is not a refactoring.
"""
import sys

from step4_order import MENU, Order, OrderLine, money, print_bill, read_orders


class MergingOrder(Order):
    def add(self, item, quantity):
        for i, line in enumerate(self._lines):
            if line.item == item:  # "tidier": one line per item
                self._lines[i] = OrderLine(item, line.quantity + quantity)
                return
        self._lines.append(OrderLine(item, quantity))


def main():
    bill_no = 0
    day_total = 0
    for codes in read_orders(sys.stdin):
        order = MergingOrder()
        for code, qty in codes:
            order.add(MENU.find(code), qty)
        bill_no += 1
        day_total += print_bill(bill_no, order)
    print(f"Day: {bill_no} bills, {money(day_total)} taken")


if __name__ == "__main__":
    main()

Input (standard input)

TEA 2
SAM 3
TEA 1

THA 4
COF 2
DOS 1

Output

Bill 1
  Masala tea      3 x  15.00    45.00
  Samosa          3 x  18.00    54.00
  Subtotal                      99.00
  Tax 5%                         4.95
  Total                        103.95

Bill 2
  Veg thali       4 x 120.00   480.00
  Filter coffee   2 x  20.00    40.00
  Masala dosa     1 x  60.00    60.00
  Subtotal                     580.00
  Discount 10%                 -58.00
  Tax 5%                        26.10
  Total                        548.10

Day: 2 bills, 652.05 taken

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

The totals are the same, but the bill is not: two tea lines became one, and the comparison stops at line 2. Merging may well be what the canteen wants, but it changes behaviour, not structure. Finish the refactoring, then make that change on its own, read the new output carefully and record it as the new golden output. The same goes for the small bug the golden output has kept: for a single order the script prints Day: 1 bills. Note it, and fix it after the refactoring, as a change of its own.

What the comparison cannot see

The golden output proves “same output” only for the inputs it was made from. A line with an unknown code and a bad quantity was not one of them:

An input the golden set does not contain Python · canteen/untried_input.py
"""An input that the golden set does not contain: an unknown code and a bad quantity."""
from check_steps import run

PROGRAMS = [
    "canteen_script.py",
    "step1_local_state.py",
    "step2_read_orders.py",
    "step5_bill.py",
]

for program in PROGRAMS:
    lines, error = run(program, "XYZ two\n")
    print(f"{program:<21} {error}")

Output

canteen_script.py     KeyError: 'XYZ'
step1_local_state.py  KeyError: 'XYZ'
step2_read_orders.py  ValueError: invalid literal for int() with base 10: 'two'
step5_bill.py         ValueError: invalid literal for int() with base 10: 'two'

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

The script and step 1 look the code up first and stop with a KeyError. From step 2 on, read_orders() converts the quantity before anything is looked up, so they stop with a ValueError. Both reject the line, so the difference does no harm here, but nobody would have noticed it without trying that input. When you find such a case, add it to the inputs and decide whether the difference matters.

Compare the testability

With the script, every test is a whole run: an order goes in on standard input and the test searches the printed text. With the object model, each rule can be checked on its own, in a line or two:

test_bill.py, one test for each rule Python · canteen/test_bill.py
"""Tests of the object model from step 5: each rule is checked on its own,
without standard input, without printing and without a whole bill to compare."""
from step5_bill import Bill, Menu, MenuItem, Order

TEA = MenuItem("TEA", "Masala tea", 1500)
COFFEE = MenuItem("COF", "Filter coffee", 2000)
SAMOSA = MenuItem("SAM", "Samosa", 1800)
THALI = MenuItem("THA", "Veg thali", 12000)


def order_of(*lines):
    order = Order()
    for item, quantity in lines:
        order.add(item, quantity)
    return order


def test_no_discount_below_500():
    assert Bill(1, order_of((THALI, 4), (TEA, 1))).discount == 0  # 495.00


def test_discount_from_exactly_500():
    assert Bill(1, order_of((THALI, 4), (COFFEE, 1))).discount == 5000


def test_tax_rounds_half_a_paisa_up():
    # 513.00 - 51.30 = 461.70, and 5% of that is 23.085
    assert Bill(1, order_of((THALI, 4), (SAMOSA, 1), (TEA, 1))).tax == 2309


def test_orders_do_not_share_lines():
    first = order_of((TEA, 2))
    second = Order()
    assert second.lines == ()
    assert first.subtotal() == 3000


def test_items_with_the_same_values_are_equal():
    assert MenuItem("TEA", "Masala tea", 1500) == TEA


def test_unknown_code_is_still_a_key_error():
    try:
        Menu([TEA]).find("XYZ")
    except KeyError:
        return
    raise AssertionError("expected a KeyError")


if __name__ == "__main__":
    for name, test in list(globals().items()):
        if name.startswith("test_"):
            test()
            print("passed", name)

Output

passed test_no_discount_below_500
passed test_discount_from_exactly_500
passed test_tax_rounds_half_a_paisa_up
passed test_orders_do_not_share_lines
passed test_items_with_the_same_values_are_equal
passed test_unknown_code_is_still_a_key_error

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

The tax test shows the difference best. Every price on this menu is a whole number of rupees, so half a paisa of tax appears only after the discount, and the test needs an order of more than 500.00. Against the script you would type such an order, run the whole program and look for 23.09 in the bill; here it is one Bill and one comparison. The tests are plain functions that use assert, and the loop at the bottom runs each one and prints its name.

Testing the script and the object model
Criterion The scriptThe objects
Tests call The whole program, through standard input and printed textEach class and function, with values made in the test
One rule Build an order that triggers it, then compare the whole billMake a Bill and compare its discount or its tax
Shared state Three globals, so each test runs the script afreshNone that changes: each Order and Bill is new
New layout Breaks every golden outputBreaks only tests of printed text
When to choose A short program, run and checked as a wholeCode that keeps changing, with rules to test one at a time

Classes are not the only tool

A class is one way to give data a single home. A module of plain functions over data that never changes is another, and it is often the right first step. The JavaScript version below uses both. The menu is a module of frozen data and one function; Order and Bill are classes, because an order guards its lines while they are added and a bill works out its amounts once and then freezes itself. It reads the same two orders and prints the same bills as the Python steps:

The same design in JavaScript modules

canteen-js/main.mjs

// The canteen bills in JavaScript: the same orders and the same output as the
// Python steps. main.mjs only reads orders, builds bills and prints them.
import { findItem } from './menu.mjs';
import { Order } from './order.mjs';
import { Bill, money, renderBill } from './bill.mjs';

const ORDERS = `TEA 2
SAM 3
TEA 1

THA 4
COF 2
DOS 1
`;

// Each order as a list of [code, quantity]; a blank line ends an order.
function* readOrders(text) {
  let order = [];
  for (const row of text.split('\n').map((r) => r.trim())) {
    if (row) {
      const [code, quantity] = row.split(/\s+/);
      order.push([code, Number(quantity)]);
    } else if (order.length) {
      yield order;
      order = [];
    }
  }
  if (order.length) yield order;
}

let bills = 0;
let dayTotal = 0;
for (const codes of readOrders(ORDERS)) {
  const order = new Order();
  for (const [code, quantity] of codes) order.add(findItem(code), quantity);
  bills += 1;
  const bill = new Bill(bills, order);
  console.log(renderBill(bill));
  dayTotal += bill.total;
}
console.log(`Day: ${bills} bills, ${money(dayTotal)} taken`);

Output

Bill 1
  Masala tea      2 x  15.00    30.00
  Samosa          3 x  18.00    54.00
  Masala tea      1 x  15.00    15.00
  Subtotal                      99.00
  Tax 5%                         4.95
  Total                        103.95

Bill 2
  Veg thali       4 x 120.00   480.00
  Filter coffee   2 x  20.00    40.00
  Masala dosa     1 x  60.00    60.00
  Subtotal                     580.00
  Discount 10%                 -58.00
  Tax 5%                        26.10
  Total                        548.10

Day: 2 bills, 652.05 taken

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node main.mjs

canteen-js/menu.mjs

// The menu as a module of data and functions: frozen objects that nothing can
// change, and one function that finds an item by its code. Prices are in paise.
const ITEMS = [
  { code: 'TEA', name: 'Masala tea', price: 1500 },
  { code: 'COF', name: 'Filter coffee', price: 2000 },
  { code: 'SAM', name: 'Samosa', price: 1800 },
  { code: 'DOS', name: 'Masala dosa', price: 6000 },
  { code: 'THA', name: 'Veg thali', price: 12000 },
];

export const MENU = Object.freeze(
  Object.fromEntries(ITEMS.map((item) => [item.code, Object.freeze(item)])),
);

export function findItem(code) {
  // Object.hasOwn, not MENU[code] alone: MENU.toString exists on every object.
  if (!Object.hasOwn(MENU, code)) throw new Error(`no menu item with code ${code}`);
  return MENU[code];
}

canteen-js/order.mjs

// An order keeps its lines in a private field: callers add items and read a
// copy of the lines, but cannot change the order behind its back.
export class Order {
  #lines = [];

  add(item, quantity) {
    this.#lines.push(Object.freeze({ item, quantity, amount: item.price * quantity }));
  }

  get lines() {
    return [...this.#lines];
  }

  subtotal() {
    return this.#lines.reduce((sum, line) => sum + line.amount, 0);
  }
}

canteen-js/bill.mjs

// A Bill works out every amount once; renderBill() only turns a bill into text.
export const DISCOUNT_FROM = 50000; // paise: orders of 500.00 or more get the discount
export const DISCOUNT_PERCENT = 10;
export const TAX_PERCENT = 5;

export class Bill {
  constructor(number, order) {
    this.number = number;
    this.lines = Object.freeze(order.lines); // a copy: freezing it leaves the order alone
    this.subtotal = order.subtotal();
    this.discount = 0;
    if (this.subtotal >= DISCOUNT_FROM) {
      this.discount = Math.floor((this.subtotal * DISCOUNT_PERCENT) / 100);
    }
    // Half a paisa or more rounds up.
    this.tax = Math.floor(((this.subtotal - this.discount) * TAX_PERCENT + 50) / 100);
    this.total = this.subtotal - this.discount + this.tax;
    Object.freeze(this);
  }
}

export function money(paise) {
  return `${Math.floor(paise / 100)}.${String(paise % 100).padStart(2, '0')}`;
}

export function renderBill(bill) {
  const row = (label, amount) => `  ${label.padEnd(26)}${amount.padStart(9)}`;
  const text = [`Bill ${bill.number}`];
  for (const line of bill.lines) {
    const name = line.item.name.padEnd(14);
    const quantity = String(line.quantity).padStart(3);
    const price = money(line.item.price).padStart(6);
    text.push(`  ${name}${quantity} x ${price}${money(line.amount).padStart(9)}`);
  }
  text.push(row('Subtotal', money(bill.subtotal)));
  if (bill.discount) {
    text.push(row(`Discount ${DISCOUNT_PERCENT}%`, `-${money(bill.discount)}`));
  }
  text.push(row(`Tax ${TAX_PERCENT}%`, money(bill.tax)));
  text.push(row('Total', money(bill.total)));
  return `${text.join('\n')}\n`;
}
  • A module that imports MENU cannot give that name a new value: the language makes every imported name an immutable binding, so an assignment throws a TypeError. The object behind the name could still be changed, though, and Object.freeze() stops that. Freezing is shallow, so menu.mjs freezes every item as well as the menu that holds them, and a Bill freezes its copy of the lines as well as itself.
  • #lines is a private field: code outside Order cannot read or change it, and even naming it there is a SyntaxError. Python’s leading underscore in _lines is only a convention: it tells other programmers that the name is not part of the public interface, and Python does not enforce it.
  • findItem() checks the code with Object.hasOwn(), because a plain lookup such as MENU["toString"] would find a method that every object inherits.

Version note

Private fields such as #lines, and Object.hasOwn(), are part of ECMAScript 2022. Node.js 24 and the QuickJS engine that runs these files in the browser both support them.

Go has no class keyword at all. Its FAQ answers the question “Is Go an object-oriented language?” with “Yes and no”: types have methods, but there is no type hierarchy. In Go the same design would be a package with an order type, its methods and plain functions for the rest.

Key takeaways

  • Objects pay off where data and the rules that guard it change together; a short script that runs a few times may not need them.
  • Find the change points first: list likely requests and see where each one lands. Requests that land in many places, or unrelated requests that land in the same function, mark the code to restructure.
  • Record a golden output for inputs that cover each rule, each edge and each error, and compare after every small step. It proves the same output only for those inputs.
  • Remove global mutable state first: globals that change make tests depend on each other.
  • Change structure or change behaviour, never both in one step. Improvements and bug fixes come after the refactoring, each with a new golden output.
  • Plain functions and frozen data are often enough; give a class the data that has rules to guard.

Exercise

Exercise · Easy · Python

Refactor a library-fines script into classes

fines.py is a working procedural script: it prints a report of library fines from a global list of loans. Refactor it into at least three classes without changing what it prints. The sample tests use this fixed API:

  • Item(title, kind): a book, magazine or DVD, where kind is "book", "magazine" or "dvd".
  • Loan(member, item, days_late) with a method fine() that returns the fine in paise: nothing for up to GRACE_DAYS days late, then PER_DAY[kind] for every further day, and never more than MAX_FINE.
  • Ledger() with add(loan) and total_for(member), the sum of that member's fines (0 for a member with no loans).
  • main(), which prints the same report as the original script every time it is called.

Item and Loan are values: two made from the same arguments are equal, and assigning to one of their fields raises an AttributeError. Keep the constants PER_DAY, GRACE_DAYS and MAX_FINE and the function money(), and keep importing fines.py silent: only main() prints.

Two of the sample tests are the safety net from the lesson: the report is compared with the original output, and main() is called twice in one program. Run the sample tests on the starter code first: the report test passes, and the second call to main() already shows the problem with the global list.

Starter code · fines.py

"""Library fines, as a procedural script: refactor it into classes.

Fines are in paise (100 paise make one rupee). A loan is fined for each day it
is late after the grace days, and no loan is fined more than MAX_FINE.
"""

PER_DAY = {"book": 200, "magazine": 100, "dvd": 500}  # paise for each day late
GRACE_DAYS = 2
MAX_FINE = 10000

loans = []  # (member, title, kind, days late)


def add_loan(member, title, kind, days_late):
    loans.append((member, title, kind, days_late))


def fine(kind, days_late):
    late = days_late - GRACE_DAYS
    if late <= 0:
        return 0
    return min(late * PER_DAY[kind], MAX_FINE)


def money(paise):
    return f"{paise // 100}.{paise % 100:02d}"


def print_report():
    totals = {}
    print(f"{'Member':<7}{'Title':<22}{'Late':>5}{'Fine':>8}")
    for member, title, kind, days_late in loans:
        amount = fine(kind, days_late)
        totals[member] = totals.get(member, 0) + amount
        print(f"{member:<7}{title:<22}{days_late:>5}{money(amount):>8}")
    parts = [f"{member} {money(total)}" for member, total in sorted(totals.items())]
    print("Totals:", ", ".join(parts))


def main():
    add_loan("Asha", "Wings of Fire", "book", 5)
    add_loan("Ravi", "Film Studies Monthly", "magazine", 1)
    add_loan("Asha", "Planet Earth", "dvd", 40)
    add_loan("Mei", "Science Weekly", "magazine", 3)
    add_loan("Ravi", "The Hobbit", "book", 9)
    print_report()


if __name__ == "__main__":
    main()
The sample tests · test_fines.py
import contextlib
import importlib
import io

import fines

GOLDEN = """\
Member Title                  Late    Fine
Asha   Wings of Fire             5    6.00
Ravi   Film Studies Monthly      1    0.00
Asha   Planet Earth             40  100.00
Mei    Science Weekly            3    1.00
Ravi   The Hobbit                9   14.00
Totals: Asha 106.00, Mei 1.00, Ravi 14.00
"""


def printed_by_main():
    printed = io.StringIO()
    with contextlib.redirect_stdout(printed):
        fines.main()
    return printed.getvalue()


def test_report_is_unchanged():
    """main() prints exactly what the original script printed"""
    assert printed_by_main() == GOLDEN


def test_report_is_the_same_the_second_time():
    """main() prints the same report when it runs twice in one program"""
    printed_by_main()
    assert printed_by_main() == GOLDEN


def test_fine_counts_the_days_after_the_grace_days():
    """Loan.fine() charges each day late after the grace days"""
    assert fines.Loan("Asha", fines.Item("Wings of Fire", "book"), 5).fine() == 600
    assert fines.Loan("Mei", fines.Item("Science Weekly", "magazine"), 3).fine() == 100


def test_no_fine_within_the_grace_days():
    """a loan up to GRACE_DAYS late is not fined"""
    for days in (0, 1, 2):
        assert fines.Loan("Mei", fines.Item("The Hobbit", "book"), days).fine() == 0


def test_fine_is_capped():
    """no loan is fined more than MAX_FINE"""
    dvd = fines.Item("Planet Earth", "dvd")
    assert fines.Loan("Asha", dvd, 21).fine() == 9500
    assert fines.Loan("Asha", dvd, 40).fine() == 10000


def test_ledger_totals_per_member():
    """Ledger.total_for() adds up one member's fines"""
    ledger = fines.Ledger()
    ledger.add(fines.Loan("Asha", fines.Item("Wings of Fire", "book"), 5))
    ledger.add(fines.Loan("Asha", fines.Item("Planet Earth", "dvd"), 40))
    ledger.add(fines.Loan("Mei", fines.Item("The Hobbit", "book"), 9))
    assert ledger.total_for("Asha") == 10600
    assert ledger.total_for("Mei") == 1400
    assert ledger.total_for("Nobody") == 0


def test_ledgers_do_not_share_loans():
    """a new Ledger starts empty, whatever another Ledger holds"""
    first = fines.Ledger()
    first.add(fines.Loan("Asha", fines.Item("Wings of Fire", "book"), 5))
    assert fines.Ledger().total_for("Asha") == 0


def test_items_and_loans_are_values():
    """Item and Loan compare by value, and their fields cannot be changed"""
    book = fines.Item("The Hobbit", "book")
    loan = fines.Loan("Mei", book, 9)
    assert book == fines.Item("The Hobbit", "book")
    assert loan == fines.Loan("Mei", fines.Item("The Hobbit", "book"), 9)
    for value, field, new in ((book, "kind", "dvd"), (loan, "days_late", 0)):
        try:
            setattr(value, field, new)
        except AttributeError:  # what a frozen dataclass raises
            continue
        raise AssertionError(f"{type(value).__name__}.{field} could be changed")


def test_import_prints_nothing():
    """importing fines.py prints nothing: only main() prints the report"""
    printed = io.StringIO()
    with contextlib.redirect_stdout(printed):
        importlib.reload(fines)
    assert printed.getvalue() == ""
A hint

Start with the values: an Item is a title and a kind, and a Loan is a member, an item and the days late that can work out its own fine from GRACE_DAYS, PER_DAY and MAX_FINE (a frozen dataclass suits both). Then give Ledger the list that is the global loans now, and let main() create a new Ledger each time it runs, so a second call starts empty.

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.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 What does shared_lines.py print?

    What does this program print? Choose one answer.

    """A half-done refactoring: functions exist, but the lines still live in a global."""
    lines = []  # one list, shared by every caller in this program
    
    
    def add(name, price, quantity):
        lines.append((name, price * quantity))
    
    
    def subtotal():
        return sum(amount for name, amount in lines)
    
    
    # Two independent checks, the way two tests would make them:
    add("Masala tea", 1500, 2)
    print("first order:", subtotal())
    add("Samosa", 1800, 1)
    print("second order:", subtotal())  # meant to be 1800
    Show the answer to question 1

    Answer: it prints

    first order: 3000
    second order: 4800

    lines is one module-level list. The second call to add() appends to the same list, so subtotal() still counts the first order's tea: 3000 + 1800 = 4800 paise. Two tests written this way pass or fail depending on the order in which they run.

  2. Question 2 of 6 Which of these requests would change print_bill() in the original script, canteen_script.py?

    Choose every answer that is right.

    Show the answer to question 2

    Answer:

    • Change the tax from 5% to 12%
    • Print the bill as JSON for a kiosk
    • Give 15% off from 400.00 instead of 10% from 500.00

    The discount, the tax and every print() are inside print_bill(), so requests about money and about looks all land in that one function. A new dish changes only MENU, and the input format is read by the loop at the bottom of the file.

  3. Question 3 of 6 After step 4 the comparison reports one "Masala tea 3" line where the script printed two tea lines. The totals are the same. What should you do?

    Choose one answer.

    Show the answer to question 3

    Answer: Undo the merging and finish the refactoring; merge lines later as a change of its own, with a new golden output

    A refactoring step must not change what the program prints, and merging lines does. Keeping it, or rewriting the golden output in the middle of the refactoring, would hide any other change made in the same step. Make the improvement after the refactoring, on its own, and then record its output as the new golden output.

  4. Question 4 of 6 Why can test_bill.py check the tax rounding with a single comparison?

    Choose one answer.

    Show the answer to question 4

    Answer: Bill works out the tax from an order made in the test, with no standard input and no printed text to search

    Both versions keep integer paise and round the tax the same way: check_steps.py shows they print the same, and it runs the script from Python with runpy. What differs is the way in. A Bill can be made directly from an Order, so a test reads bill.tax instead of feeding a whole order to the program and searching its output.

  5. Question 5 of 6 Put one safe refactoring step in order.

    Give each item its position, from 1 (first).

    Show the answer to question 5

    Answer:

    1. Record the golden output of the original program for a set of inputs
    2. Make one small change to the structure of the code
    3. Run the changed program on the same inputs
    4. Compare what it printed with the golden output
    5. Keep the change if nothing differs, otherwise fix or undo it

    The golden output comes first, before anything changes. After each small change the same inputs run again, and only a step whose output is unchanged is kept; a difference means the step changed behaviour.

  6. Question 6 of 6 In the JavaScript version, why is the menu a module of frozen data and a function rather than a class?

    Choose one answer.

    Show the answer to question 6

    Answer: Its data never changes after the module has loaded, so there is no changing state for a class to guard

    order.mjs exports a class, so modules can export classes, and Object.freeze() works on any object. The menu is fixed data with one lookup, which frozen objects and a function handle well; Order and Bill are classes because an order guards its lines while they are added and a bill works out its amounts once.

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.