Code that runs is not the same as code you trust. Your tracker works today, but it saves data, does maths on money, and now runs unattended, which means a quiet mistake could go unnoticed for weeks. This part is about catching mistakes on purpose: finding them fast when something breaks, and writing a few small tests that shout the moment a change breaks something that used to work.
print or a real debugger you open with breakpoint(). To stay safe as you change code, write a handful of tests: small functions that call your code and assert the answer, run with pytest. A few tests on the tricky parts catch far more than their size suggests.This part draws on functions from Part 9, the automation script from Part 17, and pip from Part 14 for the test tool. You need Python 3 and about thirty minutes. Every snippet runs as printed.
Looking inside a running program
The oldest debugging tool is print, and it is not something to be ashamed of. Dropping a print in to see a value at a certain point is fast and often enough. Anyone who tells you real programmers never print debug has forgotten how much they do it. That said, Python has a proper debugger built in, and one word opens it. Put breakpoint() on a line and run the file; execution pauses there and gives you an interactive prompt inside your program:
def total(items):
result = 0
for n in items:
breakpoint()
result += n
return result
print(total([10, 20]))-> result += n (Pdb) p n 10 (Pdb) c
At that (Pdb) prompt you inspect anything. p n prints the value of n right now, c continues to the next time the line is hit or the program ends. The debugger lets you watch values change step by step instead of guessing, which is the difference between poking at code and understanding it.
| At the (Pdb) prompt | What it does |
|---|---|
p name | Print the value of a variable |
n | Run the next line, stay in this function |
s | Step into a function being called |
c | Continue until the next stop or the end |
q | Quit the debugger |
Table 1. The five debugger commands that handle almost every session.
A test is just code that checks your code
A test is a small function that runs a piece of your program and asserts what the answer should be. The word assert checks that something is true and does nothing when it is, or stops loudly when it is not. Here is a plain test with no tools at all, for the monthly_totals function from Part 17:
from monthly_summary import monthly_totals
def test_groups_by_month():
data = [
{'date': '2026-06-01', 'amount': 100.0},
{'date': '2026-06-15', 'amount': 110.0},
{'date': '2026-07-02', 'amount': 40.0},
]
totals = monthly_totals(data)
assert totals['2026-06'] == 210.0
assert totals['2026-07'] == 40.0Now run it. The standard tool is pytest, a package you install with pip. It finds files named test_ something, runs every function named test_ something, and reports which passed:
pip install pytest pytest -q
. [100%] 1 passed in 0.01s
The single dot is your passing test. Change monthly_totals in a way that breaks the grouping, run pytest again, and the dot becomes an F with a clear report of what it expected against what it got. That is the whole point: the test remembers the correct answer so you do not have to.
The bug that hides in your totals
Here is a real trap, and a reason to test money code in particular. Computers store decimals in binary, and some simple looking sums do not land where you expect:
>>> 0.1 + 0.2 0.30000000000000004 >>> 0.1 + 0.2 == 0.3 False
This is not a Python bug; every language that uses standard floating point does the same. It means a test that compares two float results with == can fail even when your logic is right. The fix is to compare with a tolerance using math.isclose, which asks whether two numbers are close enough rather than exactly equal:
import math
def test_total_is_close():
total = 0.1 + 0.2
assert math.isclose(total, 0.3)1 passed
Knowing this saves you an afternoon of staring at a test that looks obviously correct. When money and floats meet, compare with math.isclose, or work in whole cents as integers, and never lean on exact equality for a calculated decimal.
How much testing is enough
Here is the opinion, against two loud camps. You do not need to test every line, chase a coverage percentage, or follow test driven development to the letter as a beginner. You also should not skip tests entirely and promise to add them later, because later rarely comes. The useful middle is a handful of tests on the parts that are easy to get wrong: parsing input, totalling money, edge cases like an empty file or a missing field. Those few tests catch most real regressions for a fraction of the effort, and they are the ones a reviewer actually wants to see.
A test is also a form of documentation. When someone reads test_groups_by_month, they learn exactly what monthly_totals is supposed to do from a concrete example, which is often clearer than a paragraph describing it. Write tests for the behaviour you would be upset to see break, and stop there until the code grows.
assert in it always passes, because nothing ever checks a result. It gives you a green tick and zero safety. If a test cannot fail, it is not testing anything. Every test needs at least one assertion that would genuinely break if the code were wrong, and it is worth deliberately breaking your code once to confirm the test actually catches it.breakpoint() to understand a failure instead of guessing, are trusted with bigger changes sooner. This is the habit that separates code that limps along from code a team can build on.0.1 + 0.2 not equal 0.3 in Python?" A clean answer: floating point numbers are stored in binary, and many decimals cannot be represented exactly, so the sum is very slightly off. The practical takeaway they want to hear is that you compare floats with a tolerance, using math.isclose, rather than exact equality. It is a small question that reveals whether you have been bitten by real number handling.add(a, b) that returns their sum, and a test test_add that asserts add(2, 3) equals 5. Run it with pytest. Expected output:
1 passed
Show solution
def add(a, b):
return a + b
def test_add():
assert add(2, 3) == 5Put both in a file whose name starts with test_, then run pytest. Because these are whole numbers, plain == is safe here; only calculated decimals need math.isclose.
Questions about debugging and tests
Is print debugging bad practice? No. It is quick and everyone uses it. Learn breakpoint() as well, because a debugger is far better when you need to watch several values change over many steps.
Do I need pytest, or is there a built in option? Python ships with unittest, which needs no install. Most people prefer pytest because tests are plain functions with plain assert. Either is fine; pytest is the common choice.
Where do test files go? Alongside your code or in a tests folder, named starting with test_ so pytest finds them automatically. The functions inside also start with test_.
What is the difference between assert in a test and in real code? In a test it checks the expected result. In production code, be careful: assertions can be switched off with an optimisation flag, so do not use them for real input validation, which is the job of the exceptions from Part 12.
How many tests should a small project have? Enough to cover the parts you would hate to see break silently. For the tracker that is totalling, parsing, and the empty or missing cases, not every trivial line.
Logging, for code that runs without you
A print statement is fine while you sit and watch a program, but the automated script from Part 17 has nobody watching it. When it misbehaves at three in the morning, you want a record of what happened. The standard logging module writes timestamped messages you can leave in the code permanently, then turn up or down without deleting anything.
import logging
logging.basicConfig(level=logging.INFO)
log = logging.getLogger('tracker')
log.info('loaded %d expenses', 12)
log.warning('rate service slow')INFO:tracker:loaded 12 expenses WARNING:tracker:rate service slow
Unlike a bare print, every message carries a level, so you can leave detailed notes in the code and choose how much to show. Set the level to WARNING in production and the routine INFO lines go quiet, while anything that actually needs attention still comes through. You can also point logging at a file so the record survives after the run, which is exactly what an unattended job needs.
| Level | Use it for |
|---|---|
DEBUG | Detailed traces while developing |
INFO | Normal events worth recording |
WARNING | Something odd, but the program goes on |
ERROR | Something failed and needs attention |
Table 2. The logging levels, from everyday noise to real problems.
For a scheduled script, a couple of log.info lines at the start and finish, plus log.exception inside your except blocks from Part 12, turn a silent failure into a readable story you can follow the next morning. That small habit is what makes an automation trustworthy rather than merely working, because when it does break, and eventually it will, the log tells you exactly where.
print and with the built in debugger through breakpoint(), write small tests that assert the right answers, run them with pytest, and avoid the floating point equality trap that fools people testing money. Your tracker is now something you can change without fear. In Part 19 you polish it into a finished portfolio project, with a README and a clean structure to show off.Figure 1. Reproduce it, narrow it down, fix it, then add a test so it does not come back.
References
The pdb debugger (official docs) · math.isclose · pytest documentation


DrJha