RizTech Academy logo
RizTech Academy
Capstone ProjectLesson 4 of 540 min

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

  1. Write all eighteen tests. Run them.
  2. Break total_paise and confirm the failure names the behaviour.
  3. Break the corrupt-file handling and confirm that test catches it.
  4. Add a test for an expense with a note longer than 200 characters.
  5. Add a test that two expenses with identical fields are equal, and one with different ids is not.
  6. Run pytest --cov=src. Find one untested branch and decide, deliberately, whether it is worth testing.
  7. Convert the four category-validation tests into one parametrize.
  8. Add a CLI test using capsys that checks add prints 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