Adding tests
The program works. This lesson makes it safe to change — and you will notice how much easier testing is because of decisions made in the planning lesson, which is the real point.
Why this is easy now
reporting.py contains pure functions: values in, values out, nothing read from
disk and nothing printed. Testing those needs no setup at all.
Had the filtering lived inside cmd_list, mixed with loading and printing,
every test would need a data file and captured output. Module 10 said code that
is hard to test is usually badly structured; here the structure came first and
testing is the reward.
Testing the model
# tests/test_models.py
from datetime import date
import pytest
from expense_tracker.models import Expense
def make_expense(**overrides) -> Expense:
"""An Expense with sensible defaults, overridable per test."""
values = {
"id": 1,
"amount_paise": 25000,
"category": "food",
"date": date(2026, 9, 27),
"note": "",
}
return Expense(**{**values, **overrides})
def test_rupees_converts_from_paise():
assert make_expense(amount_paise=25000).rupees == 250.0
def test_rejects_zero_amount():
with pytest.raises(ValueError, match="must be positive"):
make_expense(amount_paise=0)
def test_rejects_negative_amount():
with pytest.raises(ValueError, match="must be positive"):
make_expense(amount_paise=-100)
def test_rejects_unknown_category():
with pytest.raises(ValueError, match="unknown category"):
make_expense(category="groceries")
def test_error_lists_the_valid_categories():
with pytest.raises(ValueError, match="food, transport, rent, other"):
make_expense(category="groceries")
The make_expense helper is worth the eight lines. Without it every test
repeats five fields, and a sixth field added later means editing every test.
With it, each test states only what it cares about — which is also what makes
the test readable.
{**values, **overrides} is dictionary merging from module 4: defaults first,
overrides winning.
Note match= checks the message. test_error_lists_the_valid_categories is
testing something real — a message that says what is acceptable, not just what
was wrong.
Round-tripping
def test_to_dict_and_back_gives_an_equal_expense():
original = make_expense(note="lunch")
assert Expense.from_dict(original.to_dict()) == original
def test_from_dict_handles_a_missing_note():
data = {
"id": 1,
"amount_paise": 25000,
"category": "food",
"date": "2026-09-27",
}
assert Expense.from_dict(data).note == ""
The first works because @dataclass generated __eq__ — module 9. It is a
strong test: every field must survive the JSON boundary, and adding a field to
to_dict but forgetting from_dict fails here.
The second covers the older-file case the code deliberately handles.
Testing the reporting
# tests/test_reporting.py
from datetime import date
import pytest
from expense_tracker.models import Expense
from expense_tracker.reporting import (
by_category, filter_expenses, format_table, total_paise,
)
@pytest.fixture
def expenses() -> list[Expense]:
return [
Expense(1, 25000, "food", date(2026, 9, 1)),
Expense(2, 1500000, "rent", date(2026, 9, 1)),
Expense(3, 12000, "food", date(2026, 10, 3)),
]
def test_total_sums_every_amount(expenses):
assert total_paise(expenses) == 1537000
def test_total_of_nothing_is_zero():
assert total_paise([]) == 0
def test_filter_by_category(expenses):
result = filter_expenses(expenses, category="food")
assert [e.id for e in result] == [1, 3]
def test_filter_by_month(expenses):
result = filter_expenses(expenses, month="2026-10")
assert [e.id for e in result] == [3]
def test_filters_combine(expenses):
assert filter_expenses(expenses, category="food", month="2026-10") == [expenses[2]]
def test_no_filters_returns_everything(expenses):
assert filter_expenses(expenses) == expenses
def test_filter_with_no_matches_is_empty(expenses):
assert filter_expenses(expenses, category="transport") == []
def test_by_category_groups_amounts(expenses):
assert by_category(expenses) == {"food": 37000, "rent": 1500000}
def test_format_table_says_so_when_empty():
assert format_table([]) == "No expenses found."
A fixture rather than a module-level list, so each test gets a fresh one — module 10's independence rule.
Two of these deserve pointing out.
test_total_of_nothing_is_zero is the empty case, which is where the
ZeroDivisionError would have come from had total_paise divided.
test_total_sums_every_amount asserts 1537000, worked out by hand. Writing
sum(e.amount_paise for e in expenses) would re-implement the function and test
nothing — module 10's warning.
Testing storage with a real file
# tests/test_storage.py
from datetime import date
import pytest
from expense_tracker.models import Expense
from expense_tracker.storage import load, save
def test_load_of_a_missing_file_is_empty(tmp_path):
expenses, next_id = load(tmp_path / "nothing.json")
assert expenses == []
assert next_id == 1
def test_save_then_load_round_trips(tmp_path):
path = tmp_path / "expenses.json"
original = [Expense(1, 25000, "food", date(2026, 9, 27), "lunch")]
save(path, original, next_id=2)
loaded, next_id = load(path)
assert loaded == original
assert next_id == 2
def test_corrupt_file_raises_rather_than_returning_empty(tmp_path):
path = tmp_path / "expenses.json"
path.write_text("this is not json", encoding="utf-8")
with pytest.raises(ValueError, match="not valid JSON"):
load(path)
def test_corrupt_file_is_not_overwritten(tmp_path):
path = tmp_path / "expenses.json"
path.write_text("this is not json", encoding="utf-8")
with pytest.raises(ValueError):
load(path)
assert path.read_text(encoding="utf-8") == "this is not json"
tmp_path from module 10 gives a real directory, unique per test, cleaned up
afterwards.
The last test is the most valuable in the suite. It checks that a decision from the planning lesson still holds — that corrupt data is never silently destroyed. That is exactly the kind of thing a well-meaning refactor breaks, and a test is the only thing that will notice.
Run it
$ pytest
========================= test session starts =========================
collected 18 items
tests/test_models.py ....... [ 38%]
tests/test_reporting.py ......... [ 88%]
tests/test_storage.py .... [100%]
========================= 18 passed in 0.08s ==========================
Eighteen tests in under a tenth of a second. Checking the same things by hand would take several minutes and you would skip most of them.
Prove they are worth something
Do this now — it is the exercise that makes testing feel real rather than dutiful.
Go into reporting.py and change total_paise to use + where it should not,
or reverse a comparison in filter_expenses. Run pytest. Watch it fail,
naming the behaviour that broke, in under a second.
Then break the corrupt-file handling by returning [], 1 instead of raising.
Watch test_corrupt_file_raises_rather_than_returning_empty catch a change that
would have silently destroyed a user's data.
Undo both.
What is not tested
Being honest about coverage:
cli.py is untested. Argument parsing and printing. It could be tested —
capsys captures output, and main() can be called with arguments — but the
value is lower because it holds almost no logic. That is the point of keeping it
thin.
No end-to-end test. Running the actual command and checking the output. Worth adding on a real project; the logic tests carry most of the weight here.
That is a deliberate judgement, not an oversight. Module 10: business logic thoroughly, glue code lightly.
Check your work
The make_expense helper is worth its eight lines: each test then states
only what it cares about, and adding a sixth field later means editing one
function rather than every test.
return Expense(**{**values, **overrides})
Dictionary merging from module 4 — defaults first, overrides winning.
The round trip.
assert Expense.from_dict(original.to_dict()) == original
A strong test, and it only works because @dataclass generated __eq__. Adding
a field to to_dict but forgetting from_dict fails here.
test_total_sums_every_amount asserts 1537000, worked out by hand.
Writing sum(e.amount_paise for e in expenses) would re-implement the function
and test nothing — module 10's warning.
total_paise([]) is 0 without special handling, because sum([]) is 0.
The test is what tells you that.
The most valuable test in the suite:
def test_corrupt_file_is_not_overwritten(tmp_path):
...
assert path.read_text(encoding="utf-8") == "this is not json"
It checks that a decision from the planning lesson still holds. That is exactly the kind of thing a well-meaning refactor breaks, and a test is the only thing that will notice.
Proving they are worth something. Breaking total_paise fails in under a
second, naming the behaviour. Breaking the corrupt-file handling catches a
change that would have silently destroyed a user's data. Do both — it is what
makes testing feel real rather than dutiful.
Why cli.py is untested. Argument parsing and printing, with almost no
logic. That is a deliberate judgement, not an oversight: business logic
thoroughly, glue code lightly. It is also why keeping the CLI thin mattered.
Practice
- Write all eighteen tests. Run them.
- Break
total_paiseand confirm the failure names the behaviour. - Break the corrupt-file handling and confirm that test catches it.
- Add a test for an expense with a note longer than 200 characters.
- Add a test that two expenses with identical fields are equal, and one with different ids is not.
- Run
pytest --cov=src. Find one untested branch and decide, deliberately, whether it is worth testing. - Convert the four category-validation tests into one
parametrize. - Add a CLI test using
capsysthat checksaddprints a confirmation line.
Next: finishing it properly.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship