Lecture 18: Testing and Debugging OOP¶
Welcome back! So you've written a class or two. How do you know it actually works?
By the end of this lecture, you should be able to:
- Design behavior before writing code (test-first thinking)
- Write tests that verify class behavior using
assert - Use
__repr__to help with debugging - Identify common OOP bugs: aliasing, missing
self, mutable defaults - Use invariants to catch bad state early
Why Testing OOP is Different¶
With standalone functions, testing is straightforward: given inputs X, do I get output Y?
With objects, it's more complex because:
- Objects have state that changes over time
- Methods can have side effects (modifying
self) - Objects can share references to the same data (aliasing)
- Inheritance can cause unexpected behavior if you're not careful
Good testing habits make all of these manageable. And we've been kind of doing these things throughout the semester, so hopefully today's content is not like a complete surprise to you!
Step 1: Design the Behavior First¶
Before writing any code, think about what your class should do. A great way to do this is to write down the expected behavior as a series of "scenarios": if I do X, then Y should happen.
Let's practice with the NeedleBed class. Before we write a transfer_to method, let's list what should happen:
Scenario: Transfer a stitch from the front bed to the back bed
- Start: front needle 3 has a stitch, back needle 3 is empty
- Action:
front.transfer_to(back, 3, 3) - After: front needle 3 is empty, back needle 3 has a stitch
- Total stitches across both beds should stay the same
Edge case: Transfer from an empty needle
- Start: front needle 5 is empty
- Action:
front.transfer_to(back, 5, 5) - After: nothing should change (you can't transfer what isn't there)
This is test-first thinking, or wishful programming. We define what "correct" means before we write the code.
Step 2: Write the Tests¶
The simplest testing tool in Python is assert:
assert expression # AssertionError if False
assert expression, "message" # AssertionError with message if False
assert is great for quick inline checks directly in or near your code.
Now let's write our tests based on the scenarios we listed above:
class NeedleBed:
"""Represents one needle bed of a knitting machine."""
def __init__(self, side, num_needles):
self.side = side
self.num_needles = num_needles
self.needles = [False] * num_needles
def __str__(self):
symbols = ['O' if n else '.' for n in self.needles]
return f"{self.side.upper()}: {''.join(symbols)}"
def set_needle(self, index, occupied=True):
if 0 <= index < self.num_needles:
self.needles[index] = occupied
def is_occupied(self, index):
return self.needles[index]
def stitch_count(self):
return sum(self.needles)
def transfer_to(self, other, from_index, to_index):
"""Transfer stitch from self[from_index] to other[to_index]."""
if self.is_occupied(from_index):
self.set_needle(from_index, False)
other.set_needle(to_index, True)
For our reference:
Scenario: Transfer a stitch from the front bed to the back bed
- Start: front needle 3 has a stitch, back needle 3 is empty
- Action:
front.transfer_to(back, 3, 3) - After: front needle 3 is empty, back needle 3 has a stitch
- Total stitches across both beds should stay the same
# --- Test Scenario 1: Normal transfer ---
front = NeedleBed("front", 8)
back = NeedleBed("back", 8)
front.set_needle(3)
total_before = front.stitch_count() + back.stitch_count()
front.transfer_to(back, 3, 3)
assert not front.is_occupied(3), "front needle 3 should be empty after transfer"
assert back.is_occupied(3), "back needle 3 should be occupied after transfer"
total_after = front.stitch_count() + back.stitch_count()
assert total_before == total_after, f"total stitches changed: {total_before} -> {total_after}"
print("Test 1 passed: normal transfer")
Test 1 passed: normal transfer
For our reference:
Edge case: Transfer from an empty needle
- Start: front needle 5 is empty
- Action:
front.transfer_to(back, 5, 5) - After: nothing should change (you can't transfer what isn't there)
# --- Test Scenario 2: Transfer from empty needle ---
front2 = NeedleBed("front", 8)
back2 = NeedleBed("back", 8)
front2.transfer_to(back2, 5, 5)
assert not front2.is_occupied(5), "front should stay empty"
assert not back2.is_occupied(5), "back should stay empty, nothing to transfer"
print("Test 2 passed: transfer from empty needle")
Test 2 passed: transfer from empty needle
print("All transfer tests passed!")
All transfer tests passed!
We are basically translating these scenarios we wrote line by line. And note how we test state before and after the method call. This is the key to testing stateful objects! We want to make sure the method call changes the state of the object to the expected state.
What would be a good third test case?
__repr__ for Debugging¶
__str__ is for human-readable output (what you see with print()). On the other hand, __repr__ is for developer-readable output. It should show enough detail to understand (or even recreate) the object.
Python uses __repr__ when you put the object in a cell and then evaluate the cell, when you call repr() on that object, or when it appears inside a list.
class NeedleBed:
def __init__(self, side, num_needles):
self.side = side
self.num_needles = num_needles
self.needles = [False] * num_needles
def __str__(self):
symbols = ['O' if n else '.' for n in self.needles]
return f"{self.side.upper()}: {''.join(symbols)}"
def __repr__(self):
active = [i for i, n in enumerate(self.needles) if n]
return f"NeedleBed({self.side!r}, {self.num_needles}, active={active})"
def set_needle(self, index, occupied=True):
if 0 <= index < self.num_needles:
self.needles[index] = occupied
Now let's see how they work differently:
bed = NeedleBed("front", 8)
bed.set_needle(2)
bed.set_needle(5)
print(str(bed)) # human-friendly: FRONT: ..O..O..
print(repr(bed)) # developer-friendly: NeedleBed('front', 8, active=[2, 5])
FRONT: ..O..O..
NeedleBed('front', 8, active=[2, 5])
__repr__ is what you see in lists:
beds = [bed, NeedleBed("back", 8)]
print(beds) # uses __repr__ for each element!
[NeedleBed('front', 8, active=[2, 5]), NeedleBed('back', 8, active=[])]
Or when directly evaluating:
bed
NeedleBed('front', 8, active=[2, 5])
In general, we should include __repr__ in every class we write. When a test fails, __repr__ helps us understand what state the object was in.
For example, compare these two error messages:
# Without __repr__:
AssertionError: expected needle 3 occupied on <__main__.NeedleBed object at 0x107aab230>
# With __repr__:
AssertionError: expected needle 3 occupied on NeedleBed('front', 8, active=[0, 1])
The second one immediately tells you what's wrong because we encoded the information in this object into its __repr__!
Common OOP Bugs¶
Now let's look at some common bugs that can trip up even experienced programmers.
Bug 1: Aliasing¶
When two variables point to the same object, modifying one affects the other. This is called aliasing, and it's one of the most common sources of confusion in OOP.
class NeedleBed:
def __init__(self, side, num_needles):
self.side = side
self.num_needles = num_needles
self.needles = [False] * num_needles
def __str__(self):
symbols = ['O' if n else '.' for n in self.needles]
return f"{self.side.upper()}: {''.join(symbols)}"
def set_needle(self, index, occupied=True):
if 0 <= index < self.num_needles:
self.needles[index] = occupied
# ---- Aliasing bug demo ----
bed1 = NeedleBed("front", 8)
bed2 = bed1 # bed2 is NOT a copy — it's the same object!
bed1.set_needle(2)
print("bed1:", bed1)
print("bed2:", bed2) # also changed!
print("Same object?", bed1 is bed2) # True
bed1: FRONT: ..O..... bed2: FRONT: ..O..... Same object? True
This is the same thing as when two variables point to the same list. These are object references that point to that same object / list so if one variable is used to modify the content, that modification is just on that object that both variables point to.
Remember we learned several ways to copy a list so that the two lists are different lists.
Shallow Copy vs Deep Copy¶
We can also make an actually independent copy of an object, use the copy module:
import copy
bed1 = NeedleBed("front", 8)
bed1.set_needle(2)
copy.copy() copies the NeedleBed object itself but shares the same needles list.
bed_shallow = copy.copy(bed1) # shallow: copies the object, but NOT its inner lists
copy.deepcopy() copies everything recursively.
bed_deep = copy.deepcopy(bed1) # deep: copies everything recursively
So now if we modify the original bed1:
# Modify bed1's needles list directly
bed1.needles[3] = True
print("bed1: ", bed1) # ..OO....
print("shallow copy:", bed_shallow) # ..OO.... <-- also changed!
print("deep copy: ", bed_deep) # ..O..... <-- independent
bed1: FRONT: ..OO.... shallow copy: FRONT: ..OO.... deep copy: FRONT: ..O.....
We'll see that the deep copy remains the same but the shallow copy changed because that list is still the same list.
Rule of thumb: if your object contains mutable attributes (lists, dicts, other objects), use deepcopy when you need a fully independent copy.
Bug 2: Forgetting self¶
This is also very common. Can you spot the bug?
class StitchCounter:
"""Tracks stitches knit per row."""
def __init__(self, row_width):
self.row_width = row_width
self.rows = []
def add_row(self, stitches=None):
if stitches is None:
stitches = row_width
self.rows.append(stitches)
counter = StitchCounter(10)
try:
counter.add_row()
except NameError as e:
print(f"Bug found: {e}")
Bug found: name 'row_width' is not defined
Inside a method, instance attributes must be accessed through self. Without self., Python looks for a local or global variable named row_width and fails because there isn't such variable defined.
# The fix is straightforward
stitches = self.row_width
Bug 3: Mutable Default Arguments¶
This is a notorious Python gotcha. What's wrong with this class?
class Yarn:
def __init__(self, color, weight, meters, tags=[]):
self.color = color
self.weight = weight
self.meters = meters
self.tags = tags
y1 = Yarn("red", "worsted", 200)
y1.tags.append("soft")
print(f"y1 tags: {y1.tags}") # ['soft']
y2 = Yarn("blue", "fingering", 400)
print(f"y2 tags: {y2.tags}") # also ['soft']!
y1 tags: ['soft'] y2 tags: ['soft']
print(y1 is y2)
False
Why are these two different Yarn objects sharing the same tags attribute?
The reason is still related to how list works, but this time an additional context as the default value for an optional attribute.
The default [] is created once when the class is defined, and shared by every object that doesn't provide its own. The fix:
def __init__(self, color, weight, meters, tags=None):
self.tags = tags if tags is not None else []
This creates a new empty list for each object.
Poll Time!¶
Let's check understanding of the common bugs.
class NeedleBed:
def __init__(self, side, num_needles):
self.side = side
self.needles = [False] * num_needles
bed_a = NeedleBed("front", 5)
bed_b = bed_a
bed_c = NeedleBed("front", 5)
bed_a.needles[0] = True
After this code runs, which beds show needles[0] as True?
Click to reveal answer...
It's the second choice because bed_b = bed_a makes bed_b an alias, and they point to the same object. bed_c is a completely separate object created with its own __init__ call, so it's unaffected.
Invariants: Catching Bad State Early¶
In addition to writing tests outside of the class, we can also check for invariant within class methods definitions. An invariant is a condition that should always be true for your object to be in a valid state. We can write a method to check it and use assert to enforce it:
class Yarn:
STANDARD_WEIGHTS = ["lace", "super fine", "fine", "light", "medium", "bulky", "super bulky", "jumbo"]
def __init__(self, color, weight, meters):
self.color = color
self.weight = weight
self.meters = meters
# invariant check
assert self._is_valid(), f"Invalid Yarn created: {self.__dict__}"
def __str__(self):
return f"{self.color} {self.weight} yarn ({self.meters}m)"
def _is_valid(self):
"""Check that the Yarn is in a valid state."""
if self.meters < 0:
return False
if self.weight not in Yarn.STANDARD_WEIGHTS:
return False
return True
def use(self, amount):
"""Use some yarn; check invariant after modification."""
self.meters -= amount
# invariant check
assert self._is_valid(), f"Yarn became invalid after use! meters={self.meters}"
As you can see here, these checks can be sprinkled around anywhere where the state might change.
# Valid yarn works fine
y = Yarn("red", "medium", 200)
y.use(50)
print(y) # red medium yarn (150m)
# Using too much triggers the invariant check
try:
y.use(300)
except AssertionError as e:
print(f"Caught invariant violation: {e}")
red medium yarn (150m) Caught invariant violation: Yarn became invalid after use! meters=-150
Invariants act like safety nets: they catch bugs at the point where the object becomes invalid, rather than letting the bug propagate and cause mysterious failures later.
Exercises¶
Let's do some exercises to try out these strategies we learned today.
Exercise 1: Write Tests First¶
Below is a NeedleBed class with a shift_stitches(offset) method that should move all stitches to the right by offset positions. Stitches that would go off the edge are dropped.
Write the tests FIRST (based on what the method should do), then implement the method.
class NeedleBed:
def __init__(self, side, num_needles):
self.side = side
self.num_needles = num_needles
self.needles = [False] * num_needles
def __str__(self):
symbols = ['O' if n else '.' for n in self.needles]
return f"{self.side.upper()}: {''.join(symbols)}"
def __repr__(self):
active = [i for i, n in enumerate(self.needles) if n]
return f"NeedleBed({self.side!r}, {self.num_needles}, active={active})"
def set_needle(self, index, occupied=True):
if 0 <= index < self.num_needles:
self.needles[index] = occupied
def is_occupied(self, index):
return self.needles[index]
def stitch_count(self):
return sum(self.needles)
def shift_stitches(self, offset):
"""Shift all stitches right by `offset` positions.
Stitches that go off the edge are dropped.
"""
# TODO: implement this
pass
# Write your tests here FIRST, then implement shift_stitches above
# Test 1: basic shift
bed = NeedleBed("front", 8)
bed.set_needle(2)
bed.set_needle(3)
bed.shift_stitches(2)
# assert ...
# assert ...
# Test 2: shift that drops stitches off the edge
# ...
# Test 3: shift by 0 (should not change anything)
# ...
print("All shift tests passed!")
Exercise 2: Debug This Class¶
The StitchCounter class below has 3 bugs. Find and fix them all. Use the test cells to help identify the problems.
Hints:
- One is a missing
selfreference - One is an aliasing issue
- One is a logic error in a comparison
class StitchCounter:
"""Tracks how many stitches have been knit per row."""
def __init__(self, row_width):
self.row_width = row_width
self.rows = []
def add_row(self, num_stitches=None):
"""Add a row. If stitches not given, use row_width."""
if num_stitches is None:
num_stitches = row_width
self.rows.append(num_stitches)
def total_stitches(self):
"""Return total number of stitches across all rows."""
return sum(self.rows)
def copy(self):
"""Return a copy of this StitchCounter."""
new_counter = StitchCounter(self.row_width)
new_counter.rows = self.rows
return new_counter
def is_rectangle(self):
"""Return True if all rows have the same stitch count."""
for row in self.rows:
if row == self.rows[0]:
return False
return True
# Tests to help you find the bugs:
counter = StitchCounter(10)
counter.add_row()
counter.add_row()
counter.add_row()
print("Total:", counter.total_stitches()) # should be 30
assert counter.total_stitches() == 30
c2 = counter.copy()
c2.add_row(5)
print("Original rows:", counter.rows) # should still be [10, 10, 10]
print("Copy rows:", c2.rows) # should be [10, 10, 10, 5]
assert counter.is_rectangle(), "all rows same width => should be True"
print("All StitchCounter tests passed!")
--------------------------------------------------------------------------- NameError Traceback (most recent call last) Cell In[24], line 4 1 # Tests to help you find the bugs: 3 counter = StitchCounter(10) ----> 4 counter.add_row() 5 counter.add_row() 6 counter.add_row() Cell In[23], line 11, in StitchCounter.add_row(self, num_stitches) 9 """Add a row. If stitches not given, use row_width.""" 10 if num_stitches is None: ---> 11 num_stitches = row_width 12 self.rows.append(num_stitches) NameError: name 'row_width' is not defined
Exercise 3: Testing for Inheritance¶
Recall the Needle and SliderNeedle classes from last lecture. Write tests for the following scenarios:
- A
SliderNeedleshould be able to use all inheritedNeedlemethods (add_loop,drop_loops,is_empty) can_hold()should return different results forNeedlevsSliderNeedleisinstance(slider, Needle)should beTrue
class Needle:
def __init__(self, bed, position):
assert bed in ('f', 'b')
self.bed = bed
self.position = position
self.loops = []
def __str__(self):
return f"{self.bed}{self.position}"
def __repr__(self):
return f"Needle({self.bed!r}, {self.position}, loops={self.loops})"
def is_empty(self):
return len(self.loops) == 0
def add_loop(self, color):
self.loops.append(color)
def drop_loops(self):
dropped = self.loops[:]
self.loops = []
return dropped
def can_hold(self):
return len(self.loops) < 4
class SliderNeedle(Needle):
MAX_SLIDER_LOOPS = 1
def __init__(self, bed, position):
super().__init__(bed, position)
self.slider_loops = []
def __str__(self):
return f"{self.bed}s{self.position}"
def add_to_slider(self, color):
if len(self.slider_loops) >= SliderNeedle.MAX_SLIDER_LOOPS:
print(f"Warning: slider on {self} is full!")
else:
self.slider_loops.append(color)
def can_hold(self):
return len(self.slider_loops) < SliderNeedle.MAX_SLIDER_LOOPS
# Write your tests here
# Test 1: SliderNeedle inherits Needle methods
# Test 2: can_hold() differs between Needle and SliderNeedle
# Test 3: isinstance checks
print("All inheritance tests passed!")
Summary¶
Today we learned some ways to test OOP code and common bugs that might appear:
- Design behavior first — write down what should happen before coding
- Write tests with
assert— test state before and after method calls - Include
__repr__in every class — it makes debugging dramatically easier - Watch for aliasing —
b = ashares the object; usecopy.deepcopy()for independent copies - Don't forget
self— inside methods, always useself.attribute - Avoid mutable defaults — use
Noneand create fresh containers in__init__ - Assert invariants — catch invalid state early before it causes mysterious failures
These testing skills will be directly useful for HW5 and your project if you decide to use classes!