Grade by Grade
C. Designing Modular Programs · Modular and Object-Oriented Design

Modifying Existing Modular Code to Add Features

A2.3 A2.2 C1.4About 75 minutes
Learning goals
  • I can read and run an unfamiliar modular program and map its structure before changing it.
  • I can write tests that record a program's current behaviour, then use them for regression testing.
  • I can choose where an enhancement belongs and make it with the smallest, safest change.
  • I can extend a program without breaking existing callers, using default parameters, new methods and new subclasses.
  • I can refactor first to make a change easy, then make the change.

Words to know

enhancement
A change that adds or improves functionality, as opposed to fixing a bug.
legacy code
Existing code that is already in use, often written by someone else, that must keep working while it is changed.
regression
A bug in something that used to work, introduced by a later change.
regression testing
Re-running existing tests after every change to make sure nothing that worked before has broken.
characterization test
A test written before changing code, recording what the code currently does.
backward compatible
A change after which code written for the old version still works without edits.
extension point
A place designed for adding behaviour, such as a superclass to subclass or a strategy parameter.
open-closed principle
Modules should be open for extension but closed for modification: add new code rather than rewriting working code.

Most programming is changing programs

In industry, very little code is written from scratch. Most of a developer's time goes into reading, fixing and extending code that already exists, often written by people who have moved on. Your school library's lending program works, and the librarian asks for two new things: due dates with an overdue list, and support for DVDs, which are lent for a shorter time.

Expectation A2.3 asks you to modify existing modular code to enhance a program's functionality. The skill is not just making the new feature work. It is making it work without breaking anything that already works, and leaving the code as clean as you found it, or cleaner.

1 Read and run it2 Test what it does now3 Find where it belongs4 Make the smallest change5 Run ALL tests again6 Update docs, changelogrepeat for each enhancement
A safe cycle for changing code you did not write. Step 5 is regression testing: re-running the old tests proves the new feature did not break anything that used to work.

Step 1: Read and run before you touch

  • Read the README and docstrings first, then main() or the demo code, to see how the modules are used.
  • Run the program and its tests. You need to know what "working" looks like now.
  • Map the structure: sketch a quick UML class diagram or structure chart from the code. Which class owns which data? Who calls whom?
  • Note the conventions already used (naming, documentation style, error handling) so your change fits in.

Ask: where would this feature naturally live? Due dates are about lending, so they belong where lending happens (Library.check_out) and where the item's state is stored (the item). A DVD is a new kind of lendable thing, so it suggests a new class.

Worked example

Example 1: Reading version 1 and pinning down its behaviour

Here is version 1 of library.py. Before changing anything, write tests that record what it does now.

  1. Map it: two classes. Book stores title, author and borrower (None means available). Library holds a private list of items and offers add, find (case-insensitive) and check_out (returns True or False).
  2. Notice the interface: other programs call Book(title, author) and lib.check_out(title, borrower). These signatures must keep working.
  3. Characterization tests record current behaviour, including edge cases: checking out by a differently-capitalized title works; checking out a book that is already out fails; a missing title fails; str(book) gives "title by author".
  4. Put the tests in their own file, test_library.py, and run it. It must pass before you change anything; otherwise you cannot tell later whether a failure is new.

Version 1 and its tests are below. The test file prints all version 1 tests passed.

"""library.py, version 1: books that can be checked out."""
class Book:
def __init__(self, title, author):
self.title = title
self.author = author
self.borrower = None
def is_available(self):
return self.borrower is None
def __str__(self):
return f"{self.title} by {self.author}"
class Library:
def __init__(self):
self._items = []
def add(self, item):
self._items.append(item)
def find(self, title):
"""Return the item with this title (any capitals), or None."""
for item in self._items:
if item.title.lower() == title.lower():
return item
return None
def check_out(self, title, borrower):
"""Lend an item. Return True on success, False if missing or out."""
item = self.find(title)
if item is None or not item.is_available():
return False
item.borrower = borrower
return True

library.py, version 1, as you received it.

"""test_library.py: tests that record what version 1 already does."""
from library import Book, Library
def make_library():
lib = Library()
lib.add(Book("Anne of Green Gables", "L. M. Montgomery"))
lib.add(Book("Indian Horse", "Richard Wagamese"))
return lib
def test_check_out():
lib = make_library()
assert lib.check_out("indian horse", "Sam") is True
assert lib.find("Indian Horse").borrower == "Sam"
assert lib.check_out("Indian Horse", "Lee") is False # already out
assert lib.check_out("No Such Book", "Lee") is False # missing
def test_str():
assert str(Book("Indian Horse", "Richard Wagamese")) == "Indian Horse by Richard Wagamese"
test_check_out()
test_str()
print("all version 1 tests passed")

test_library.py: characterization tests, written before any change. Output: all version 1 tests passed

Changing without breaking: the techniques

  • Add, don't edit. New methods (overdue) and new subclasses (DVD) add behaviour without touching working code. This is the open-closed principle.
  • New parameters get defaults. check_out(title, borrower, today=None) accepts every old call unchanged; new code can pass a date. Never add a required parameter to a function other code already calls.
  • Keep signatures stable. If Book's constructor must stay Book(title, author), keep it, even if the inside changes.
  • Refactor first, in a separate small step. If the code is not shaped for the change, reshape it (without changing behaviour), run the tests, and only then add the feature. A saying attributed to programmer Kent Beck sums it up: first make the change easy, then make the easy change.
  • Inject what varies. The due date depends on today's date, which changes every day and would make tests unpredictable. Letting the caller pass today (with the real date as the default) keeps tests repeatable: the same idea as the ask=input parameter in the reuse lesson.
version 1Booktitleauthorborroweris_available()__str__()version 2LibraryItemLOAN_DAYS = 21titleborrowerdueis_available()Bookauthor__str__()DVDLOAN_DAYS = 7__str__()
The refactoring in Example 2. The shared parts of Book move up into a new superclass, LibraryItem, so a new DVD class can reuse them. Book keeps its old constructor, so every program written for version 1 still works.
Worked example

Example 2: Version 2, with due dates, overdue lists and DVDs

Enhance the library: each item gets a due date when checked out (21 days for books, 7 for DVDs); the library can list overdue items; DVDs (title only) can be added. All version 1 tests must still pass.

  1. Refactor first: move what every lendable item has (title, borrower, is_available) from Book into a new superclass LibraryItem. Book keeps its own constructor signature and calls super().__init__(title). Run the version 1 tests: they pass, so the refactoring changed no behaviour.
  2. Add the loan length as a class attribute LOAN_DAYS = 21 in LibraryItem. DVD overrides it with 7 and defines its own __str__. No if-statements about item types anywhere: polymorphism does the work.
  3. Add due dates in check_out with a default parameter today=None. The standard-library datetime module's date and timedelta do the date arithmetic: 2 November 2026 plus 21 days is 23 November; plus 7 days is 9 November.
  4. Add a new method overdue(today) that returns the items that are out and whose due date has passed. On 20 November, only the DVD is overdue.
  5. Regression test: run the old test file against version 2. It passes unchanged, which proves backward compatibility. Then add new tests for the new features.
  6. Document: update the module docstring and add a changelog entry, for example "1.1: due dates, overdue list, DVDs (7-day loans)".

Version 2 and a demo are below. The version 1 tests still print all version 1 tests passed when run against it.

"""library.py, version 2: adds due dates, overdue lists and DVDs.
Existing code that uses Book and Library from version 1 still works.
"""
from datetime import date, timedelta
class LibraryItem:
"""Anything the library lends. Subclasses set LOAN_DAYS."""
LOAN_DAYS = 21
def __init__(self, title):
self.title = title
self.borrower = None
self.due = None
def is_available(self):
return self.borrower is None
class Book(LibraryItem):
def __init__(self, title, author): # same signature as version 1
super().__init__(title)
self.author = author
def __str__(self):
return f"{self.title} by {self.author}"
class DVD(LibraryItem):
LOAN_DAYS = 7 # overrides the class attribute
def __str__(self):
return f"{self.title} (DVD)"
class Library:
def __init__(self):
self._items = []
def add(self, item):
self._items.append(item)
def find(self, title):
"""Return the item with this title (any capitals), or None."""
for item in self._items:
if item.title.lower() == title.lower():
return item
return None
def check_out(self, title, borrower, today=None):
"""Lend an item. Return True on success, False if missing or out.
today defaults to the real date; tests can pass a fixed date.
"""
item = self.find(title)
if item is None or not item.is_available():
return False
if today is None:
today = date.today()
item.borrower = borrower
item.due = today + timedelta(days=item.LOAN_DAYS)
return True
def overdue(self, today):
"""Return the items that are out and past their due date."""
late = []
for item in self._items:
if item.borrower is not None and item.due < today:
late.append(item)
return late

library.py, version 2.

"""test_library.py: tests that record what version 1 already does."""
from library import Book, Library
def make_library():
lib = Library()
lib.add(Book("Anne of Green Gables", "L. M. Montgomery"))
lib.add(Book("Indian Horse", "Richard Wagamese"))
return lib
def test_check_out():
lib = make_library()
assert lib.check_out("indian horse", "Sam") is True
assert lib.find("Indian Horse").borrower == "Sam"
assert lib.check_out("Indian Horse", "Lee") is False # already out
assert lib.check_out("No Such Book", "Lee") is False # missing
def test_str():
assert str(Book("Indian Horse", "Richard Wagamese")) == "Indian Horse by Richard Wagamese"
test_check_out()
test_str()
print("all version 1 tests passed")

The unchanged version 1 tests, run against version 2: a successful regression test. Output: all version 1 tests passed

from datetime import date
from library import Book, DVD, Library
lib = Library()
lib.add(Book("Indian Horse", "Richard Wagamese"))
lib.add(DVD("Northern Lights Documentary"))
start = date(2026, 11, 2)
lib.check_out("Indian Horse", "Sam", today=start)
lib.check_out("northern lights documentary", "Lee", today=start)
for item in [lib.find("Indian Horse"), lib.find("Northern Lights Documentary")]:
print(item, "due", item.due)
for item in lib.overdue(date(2026, 11, 20)):
print("Overdue:", item, "borrowed by", item.borrower)

demo.py: the new features. Fixed dates make the output the same every time it runs.

Indian Horse by Richard Wagamese due 2026-11-23
Northern Lights Documentary (DVD) due 2026-11-09
Overdue: Northern Lights Documentary (DVD) borrowed by Lee

Output of the program above (what Python prints).

When the tests fail after your change

Do not "fix" the old test to match the new behaviour unless the change in behaviour was requested. A failing old test is the regression test doing its job: it has caught a place where your change broke something users depend on. Undo or rethink the change, or, if the behaviour really should change, get it agreed, update the documentation, and then update the test.

Just for fun

Every programmer eventually opens a file, thinks "Who wrote this mess?", checks the history, and finds their own name from last year. Leave code clean for the next reader: it is probably you.

Hands-on activity

Enhance a program you did not write

You need: a computer with Python and the version 1 library code and tests from this lesson (type them in), or any small program written by someone else (a classmate's, or one of this unit's examples).

  1. Read and run the program. Draw its UML diagram from the code alone.
  2. Write at least four characterization tests in a separate test file. Run them; they must pass.
  3. Choose an enhancement. For the library: a renew(title, today) method that extends the due date by the item's loan length, but only if the item is not overdue; or a Magazine class with a 3-day loan; or a search(word) method that finds titles containing a word.
  4. Plan where the change belongs and which techniques you will use (new method, new subclass, default parameter, refactor first).
  5. Make the change, run all the old tests, then add tests for the new feature.
  6. Write a two-line changelog entry and update the docstrings.

What to look for: no old test was edited; every old call in the original program still works; the new feature has its own tests.

Your turn

1

What is a regression?

2

Why write characterization tests before changing code?

3

A function check_out(title, borrower) is used in 40 places. You need to pass a date. Why is check_out(title, borrower, today=None) better than check_out(title, borrower, today)?

4

Using version 2, when is a book checked out on 2026-12-15 due?

5

Why did version 2 add a DVD subclass instead of an if item_type == "DVD" test inside check_out?

6

What does "make the change easy, then make the easy change" mean in Example 2?

7

Name three things to do after the code change works.

More practice: warm-up

1

What is an enhancement?

2

What does backward compatible mean?

3

Which module provides date and timedelta?

4

What is the first step before modifying unfamiliar code?

5

Should you edit an old test so it passes after your change?

More practice: on your own

1

Add a Magazine class to version 2 with a 3-day loan and __str__ showing the title and "(magazine)".

2

Write return_item(self, title) for Library in version 2. It should clear borrower and due, and return False if the item is missing or not out.

3

Write a test for overdue that checks a book due on 2026-11-23 is not overdue on that day but is overdue the next day.

4

A teammate "enhances" check_out by renaming it lend_item because the name is nicer. What breaks, and how could they rename it safely?

5

Why does version 2's check_out accept today as a parameter instead of always calling date.today()?

Challenge

1

Write renew(self, title, today) for version 2: an item that is out and not overdue gets its due date extended by its loan length from the current due date; return True on success. What is the new due date of the book from the demo if renewed on 2026-11-20?

2

The librarian asks: "Overdue items should print with how many days late they are." Where should the days-late calculation go, and why not in the code that prints?

3

You must add a feature to a 2000-line module that has no tests at all. Describe a safe plan.

Check your understanding

Quiz

1.

What is the main purpose of regression testing?

2.

Which change to def check_out(title, borrower): is backward compatible?

3.

In version 2, a DVD checked out on 2026-11-02 is due:

4.

Which technique follows the open-closed principle?

5.

What should you do first when asked to enhance code you have never seen?

6.

Version 1's tests pass against version 2 without being edited. What does that show?

For parents and teachers

Big idea: A2.3 asks students to modify existing modular code to enhance a program's functionality. Professional developers spend most of their time doing exactly this. The lesson teaches a safe process: understand, pin down current behaviour with tests, change in the right place with the smallest change, re-run all tests (regression testing), and update documentation. Formal test plans are covered further in the Algorithm Design and Analysis unit and the team project unit.

How to help: Ask your teen to show you the old tests passing before and after their change. If they edited an old test, ask why. A good answer mentions that the behaviour change was requested.

Common difficulties:

  • Changing code before running it or writing any tests.
  • Adding required parameters that break existing calls.
  • Adding if-chains for new types instead of subclasses or strategies.
  • "Fixing" failing old tests instead of the regression they found.
  • Forgetting to update docstrings and the changelog.