ይህ ትምህርት ገና ወደ አማርኛ አልተተረጎመም፤ ስለዚህ በእንግሊዝኛ ቀርቧል። የእንግሊዝኛውን ገጽ ክፈቱ
Project: Test Birr Ledger
Birr Ledger v1 works when you try it by hand. This project finds out whether it works when you don't: you'll write a test suite for its functions, find a real bug in v1 with it, and check the suite against planted bugs. Every milestone runs in the browser; the last section runs the same tests on your computer.
The plan
ledger.py is lesson 8.9's finished program, in a tab next to your tests. The tests go
in test_ledger.py and use the fixed names:
add_expense(expenses, amount, category, note, date=None)total_by_category(expenses)save_expenses(expenses, path=LEDGER_FILE)andload_expenses(path=LEDGER_FILE)- a fixture,
sample_expenses, from Milestone 2 on
Only main() prints and asks, so every other function can be tested by calling it.
That's why 8.9 kept them apart. Each test passes a fixed date, so its result is the same
every day.
Milestone 1: two happy paths
መልመጃ
The first two tests
Write two tests: one adds an expense to an empty list and checks the whole list, the
other adds two food expenses and checks total_by_category.
Your code
test_ledger.py
from ledger import add_expense, total_by_category
# Test 1: add one expense with a fixed date to an empty list,
# and check the whole list.
# Test 2: add two food expenses and check total_by_category.In the editor, press Escape then Tab to move on.
ledger.py
import datetime
import json
from collections import Counter
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_expenses(expenses, path=LEDGER_FILE):
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, ensure_ascii=False, indent=2)
def add_expense(expenses, amount, category, note, date=None):
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
def total_by_category(expenses):
totals = Counter()
for e in expenses:
totals[e["category"]] = totals[e["category"]] + e["amount"]
return totals
def main():
expenses = load_expenses()
while True:
command = input("Command: ")
match command:
case "add":
amount = None
while amount is None:
try:
amount = int(input("Amount: "))
except ValueError:
print("Amount must be a whole number of birr.")
category = input("Category: ")
note = input("Note: ")
add_expense(expenses, amount, category, note)
save_expenses(expenses)
print(f"Added {amount} birr for {category}.")
case "list":
if not expenses:
print("No expenses yet.")
for e in expenses:
print(e["date"], e["amount"], "birr", e["category"], e["note"])
case "summary":
for category, total in total_by_category(expenses).most_common():
print(f"{category}: {total} birr")
case "quit":
print("Goodbye!")
break
case _:
print("Unknown command. Try add, list, summary, or quit.")
if __name__ == "__main__":
main()In the editor, press Escape then Tab to move on.
መፍትሄውን አሳይHide the solution
This is one way to solve it, not the only one. If yours prints the same thing, it works.
from ledger import add_expense, total_by_category
def test_add_expense():
expenses = []
add_expense(expenses, 250, "food", "ቡና", "2026-10-01")
assert expenses == [
{"date": "2026-10-01", "amount": 250, "category": "food", "note": "ቡና"},
]
def test_total_by_category():
expenses = []
add_expense(expenses, 250, "food", "ምሳ", "2026-09-29")
add_expense(expenses, 60, "food", "ቡና", "2026-09-30")
assert total_by_category(expenses) == {"food": 310}Comparing the whole list checks all four keys at once. total_by_category returns a
Counter, and a Counter equals a dict with the same keys and values, so
== {"food": 310} works.
Milestone 2: the fixture
Most tests from here on need the same expenses: the three in 8.9's ledger.json.
መልመጃ
sample_expenses
This is the Milestone 1 file. Add the sample_expenses fixture, building the three
expenses with add_expense, and rewrite both tests to use it.
Your code
test_ledger.py
from ledger import add_expense, total_by_category
def test_add_expense():
expenses = []
add_expense(expenses, 250, "food", "ቡና", "2026-10-01")
assert expenses == [
{"date": "2026-10-01", "amount": 250, "category": "food", "note": "ቡና"},
]
def test_total_by_category():
expenses = []
add_expense(expenses, 250, "food", "ምሳ", "2026-09-29")
add_expense(expenses, 60, "food", "ቡና", "2026-09-30")
assert total_by_category(expenses) == {"food": 310}
# Add a sample_expenses fixture: 8.9's three expenses, made with add_expense.
# Then make both tests use it.In the editor, press Escape then Tab to move on.
ledger.py
import datetime
import json
from collections import Counter
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_expenses(expenses, path=LEDGER_FILE):
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, ensure_ascii=False, indent=2)
def add_expense(expenses, amount, category, note, date=None):
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
def total_by_category(expenses):
totals = Counter()
for e in expenses:
totals[e["category"]] = totals[e["category"]] + e["amount"]
return totals
def main():
expenses = load_expenses()
while True:
command = input("Command: ")
match command:
case "add":
amount = None
while amount is None:
try:
amount = int(input("Amount: "))
except ValueError:
print("Amount must be a whole number of birr.")
category = input("Category: ")
note = input("Note: ")
add_expense(expenses, amount, category, note)
save_expenses(expenses)
print(f"Added {amount} birr for {category}.")
case "list":
if not expenses:
print("No expenses yet.")
for e in expenses:
print(e["date"], e["amount"], "birr", e["category"], e["note"])
case "summary":
for category, total in total_by_category(expenses).most_common():
print(f"{category}: {total} birr")
case "quit":
print("Goodbye!")
break
case _:
print("Unknown command. Try add, list, summary, or quit.")
if __name__ == "__main__":
main()In the editor, press Escape then Tab to move on.
መፍትሄውን አሳይHide the solution
This is one way to solve it, not the only one. If yours prints the same thing, it works.
import pytest
from ledger import add_expense, total_by_category
@pytest.fixture
def sample_expenses():
expenses = []
add_expense(expenses, 120, "transport", "ታክሲ", "2026-09-28")
add_expense(expenses, 250, "food", "ምሳ", "2026-09-29")
add_expense(expenses, 60, "food", "ቡና", "2026-09-30")
return expenses
def test_add_expense(sample_expenses):
add_expense(sample_expenses, 40, "transport", "ባጃጅ", "2026-10-01")
assert len(sample_expenses) == 4
assert sample_expenses[-1] == {
"date": "2026-10-01",
"amount": 40,
"category": "transport",
"note": "ባጃጅ",
}
def test_total_by_category(sample_expenses):
assert total_by_category(sample_expenses) == {"food": 310, "transport": 120}test_add_expense adds a fourth expense, but the next test still gets three: each
test gets a fresh list (lesson 9.3).
Milestone 3: totals
መልመጃ
Totals, parametrized
This is the Milestone 2 file. Add one parametrized test for each category's total,
including rent, which has no expenses, and a test for an empty ledger. A test can
ask for a fixture and parametrize's names together, like
def test_category_total(sample_expenses, category, expected):. pytest fills in each.
Your code
test_ledger.py
import pytest
from ledger import add_expense, total_by_category
@pytest.fixture
def sample_expenses():
expenses = []
add_expense(expenses, 120, "transport", "ታክሲ", "2026-09-28")
add_expense(expenses, 250, "food", "ምሳ", "2026-09-29")
add_expense(expenses, 60, "food", "ቡና", "2026-09-30")
return expenses
def test_add_expense(sample_expenses):
add_expense(sample_expenses, 40, "transport", "ባጃጅ", "2026-10-01")
assert len(sample_expenses) == 4
assert sample_expenses[-1] == {
"date": "2026-10-01",
"amount": 40,
"category": "transport",
"note": "ባጃጅ",
}
def test_total_by_category(sample_expenses):
assert total_by_category(sample_expenses) == {"food": 310, "transport": 120}
# Parametrize: each category's total, including one with no expenses.
# Then: the totals of an empty ledger.In the editor, press Escape then Tab to move on.
ledger.py
import datetime
import json
from collections import Counter
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_expenses(expenses, path=LEDGER_FILE):
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, ensure_ascii=False, indent=2)
def add_expense(expenses, amount, category, note, date=None):
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
def total_by_category(expenses):
totals = Counter()
for e in expenses:
totals[e["category"]] = totals[e["category"]] + e["amount"]
return totals
def main():
expenses = load_expenses()
while True:
command = input("Command: ")
match command:
case "add":
amount = None
while amount is None:
try:
amount = int(input("Amount: "))
except ValueError:
print("Amount must be a whole number of birr.")
category = input("Category: ")
note = input("Note: ")
add_expense(expenses, amount, category, note)
save_expenses(expenses)
print(f"Added {amount} birr for {category}.")
case "list":
if not expenses:
print("No expenses yet.")
for e in expenses:
print(e["date"], e["amount"], "birr", e["category"], e["note"])
case "summary":
for category, total in total_by_category(expenses).most_common():
print(f"{category}: {total} birr")
case "quit":
print("Goodbye!")
break
case _:
print("Unknown command. Try add, list, summary, or quit.")
if __name__ == "__main__":
main()In the editor, press Escape then Tab to move on.
መፍትሄውን አሳይHide the solution
This is one way to solve it, not the only one. If yours prints the same thing, it works.
import pytest
from ledger import add_expense, total_by_category
@pytest.fixture
def sample_expenses():
expenses = []
add_expense(expenses, 120, "transport", "ታክሲ", "2026-09-28")
add_expense(expenses, 250, "food", "ምሳ", "2026-09-29")
add_expense(expenses, 60, "food", "ቡና", "2026-09-30")
return expenses
def test_add_expense(sample_expenses):
add_expense(sample_expenses, 40, "transport", "ባጃጅ", "2026-10-01")
assert len(sample_expenses) == 4
assert sample_expenses[-1] == {
"date": "2026-10-01",
"amount": 40,
"category": "transport",
"note": "ባጃጅ",
}
def test_total_by_category(sample_expenses):
assert total_by_category(sample_expenses) == {"food": 310, "transport": 120}
@pytest.mark.parametrize("category, expected", [
("food", 310),
("transport", 120),
("rent", 0),
])
def test_category_total(sample_expenses, category, expected):
assert total_by_category(sample_expenses)[category] == expected
def test_empty_ledger():
assert total_by_category([]) == {}A Counter gives 0 for a category with no expenses (lesson 8.9), and an empty ledger
has no totals: the empty input from lesson 9.4.
Milestone 4: bad amounts
8.9 asks again when the amount isn't a whole number, but it never said what a whole
number must be. Here's the rule, written down now: an amount must be more than 0. An
expense of -50 birr means nothing, and 0 adds nothing. Write the rule as a test first
(lesson 9.4): an amount of 0 or -50 should raise ValueError.
መልመጃ
Bad amounts, test-first
This is the Milestone 3 file. Add a parametrized test with pytest.raises for the
amounts 0 and -50. They should fail.
Your code
test_ledger.py
import pytest
from ledger import add_expense, total_by_category
@pytest.fixture
def sample_expenses():
expenses = []
add_expense(expenses, 120, "transport", "ታክሲ", "2026-09-28")
add_expense(expenses, 250, "food", "ምሳ", "2026-09-29")
add_expense(expenses, 60, "food", "ቡና", "2026-09-30")
return expenses
def test_add_expense(sample_expenses):
add_expense(sample_expenses, 40, "transport", "ባጃጅ", "2026-10-01")
assert len(sample_expenses) == 4
assert sample_expenses[-1] == {
"date": "2026-10-01",
"amount": 40,
"category": "transport",
"note": "ባጃጅ",
}
def test_total_by_category(sample_expenses):
assert total_by_category(sample_expenses) == {"food": 310, "transport": 120}
@pytest.mark.parametrize("category, expected", [
("food", 310),
("transport", 120),
("rent", 0),
])
def test_category_total(sample_expenses, category, expected):
assert total_by_category(sample_expenses)[category] == expected
def test_empty_ledger():
assert total_by_category([]) == {}
# Test that add_expense raises ValueError for an amount of 0 or -50.In the editor, press Escape then Tab to move on.
ledger.py
import datetime
import json
from collections import Counter
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_expenses(expenses, path=LEDGER_FILE):
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, ensure_ascii=False, indent=2)
def add_expense(expenses, amount, category, note, date=None):
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
def total_by_category(expenses):
totals = Counter()
for e in expenses:
totals[e["category"]] = totals[e["category"]] + e["amount"]
return totals
def main():
expenses = load_expenses()
while True:
command = input("Command: ")
match command:
case "add":
amount = None
while amount is None:
try:
amount = int(input("Amount: "))
except ValueError:
print("Amount must be a whole number of birr.")
category = input("Category: ")
note = input("Note: ")
add_expense(expenses, amount, category, note)
save_expenses(expenses)
print(f"Added {amount} birr for {category}.")
case "list":
if not expenses:
print("No expenses yet.")
for e in expenses:
print(e["date"], e["amount"], "birr", e["category"], e["note"])
case "summary":
for category, total in total_by_category(expenses).most_common():
print(f"{category}: {total} birr")
case "quit":
print("Goodbye!")
break
case _:
print("Unknown command. Try add, list, summary, or quit.")
if __name__ == "__main__":
main()In the editor, press Escape then Tab to move on.
መፍትሄውን አሳይHide the solution
This is one way to solve it, not the only one. If yours prints the same thing, it works.
import pytest
from ledger import add_expense, total_by_category
@pytest.fixture
def sample_expenses():
expenses = []
add_expense(expenses, 120, "transport", "ታክሲ", "2026-09-28")
add_expense(expenses, 250, "food", "ምሳ", "2026-09-29")
add_expense(expenses, 60, "food", "ቡና", "2026-09-30")
return expenses
def test_add_expense(sample_expenses):
add_expense(sample_expenses, 40, "transport", "ባጃጅ", "2026-10-01")
assert len(sample_expenses) == 4
assert sample_expenses[-1] == {
"date": "2026-10-01",
"amount": 40,
"category": "transport",
"note": "ባጃጅ",
}
def test_total_by_category(sample_expenses):
assert total_by_category(sample_expenses) == {"food": 310, "transport": 120}
@pytest.mark.parametrize("category, expected", [
("food", 310),
("transport", 120),
("rent", 0),
])
def test_category_total(sample_expenses, category, expected):
assert total_by_category(sample_expenses)[category] == expected
def test_empty_ledger():
assert total_by_category([]) == {}
@pytest.mark.parametrize("amount", [0, -50])
def test_bad_amount(sample_expenses, amount):
with pytest.raises(ValueError):
add_expense(sample_expenses, amount, "food", "ቡና", "2026-10-01")Both fail with DID NOT RAISE.
ጥያቄ
The two bad-amount tests fail. What's wrong?
That's a real bug in v1, found by a test. Fix it where the rule belongs, in
add_expense, and let main() handle the error.
መልመጃ
add_expense refuses bad amounts
This is ledger.py, with the Milestone 4 tests in their tab. Make add_expense raise
ValueError("amount must be more than 0") before it appends. Then, in main(), call
it inside try: print Not added: and the message on a ValueError, and save only
when it worked (else, lesson 7.2).
Your code
ledger.py
import datetime
import json
from collections import Counter
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_expenses(expenses, path=LEDGER_FILE):
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, ensure_ascii=False, indent=2)
def add_expense(expenses, amount, category, note, date=None):
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
def total_by_category(expenses):
totals = Counter()
for e in expenses:
totals[e["category"]] = totals[e["category"]] + e["amount"]
return totals
def main():
expenses = load_expenses()
while True:
command = input("Command: ")
match command:
case "add":
amount = None
while amount is None:
try:
amount = int(input("Amount: "))
except ValueError:
print("Amount must be a whole number of birr.")
category = input("Category: ")
note = input("Note: ")
add_expense(expenses, amount, category, note)
save_expenses(expenses)
print(f"Added {amount} birr for {category}.")
case "list":
if not expenses:
print("No expenses yet.")
for e in expenses:
print(e["date"], e["amount"], "birr", e["category"], e["note"])
case "summary":
for category, total in total_by_category(expenses).most_common():
print(f"{category}: {total} birr")
case "quit":
print("Goodbye!")
break
case _:
print("Unknown command. Try add, list, summary, or quit.")
if __name__ == "__main__":
main()In the editor, press Escape then Tab to move on.
test_ledger.py
import pytest
from ledger import add_expense, total_by_category
@pytest.fixture
def sample_expenses():
expenses = []
add_expense(expenses, 120, "transport", "ታክሲ", "2026-09-28")
add_expense(expenses, 250, "food", "ምሳ", "2026-09-29")
add_expense(expenses, 60, "food", "ቡና", "2026-09-30")
return expenses
def test_add_expense(sample_expenses):
add_expense(sample_expenses, 40, "transport", "ባጃጅ", "2026-10-01")
assert len(sample_expenses) == 4
assert sample_expenses[-1] == {
"date": "2026-10-01",
"amount": 40,
"category": "transport",
"note": "ባጃጅ",
}
def test_total_by_category(sample_expenses):
assert total_by_category(sample_expenses) == {"food": 310, "transport": 120}
@pytest.mark.parametrize("category, expected", [
("food", 310),
("transport", 120),
("rent", 0),
])
def test_category_total(sample_expenses, category, expected):
assert total_by_category(sample_expenses)[category] == expected
def test_empty_ledger():
assert total_by_category([]) == {}
@pytest.mark.parametrize("amount", [0, -50])
def test_bad_amount(sample_expenses, amount):
with pytest.raises(ValueError):
add_expense(sample_expenses, amount, "food", "ቡና", "2026-10-01")In the editor, press Escape then Tab to move on.
መፍትሄውን አሳይHide the solution
This is one way to solve it, not the only one. If yours prints the same thing, it works.
import datetime
import json
from collections import Counter
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_expenses(expenses, path=LEDGER_FILE):
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, ensure_ascii=False, indent=2)
def add_expense(expenses, amount, category, note, date=None):
if amount <= 0:
raise ValueError("amount must be more than 0")
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
def total_by_category(expenses):
totals = Counter()
for e in expenses:
totals[e["category"]] = totals[e["category"]] + e["amount"]
return totals
def main():
expenses = load_expenses()
while True:
command = input("Command: ")
match command:
case "add":
amount = None
while amount is None:
try:
amount = int(input("Amount: "))
except ValueError:
print("Amount must be a whole number of birr.")
category = input("Category: ")
note = input("Note: ")
try:
add_expense(expenses, amount, category, note)
except ValueError as error:
print(f"Not added: {error}.")
else:
save_expenses(expenses)
print(f"Added {amount} birr for {category}.")
case "list":
if not expenses:
print("No expenses yet.")
for e in expenses:
print(e["date"], e["amount"], "birr", e["category"], e["note"])
case "summary":
for category, total in total_by_category(expenses).most_common():
print(f"{category}: {total} birr")
case "quit":
print("Goodbye!")
break
case _:
print("Unknown command. Try add, list, summary, or quit.")
if __name__ == "__main__":
main()The menu now refuses -50 too, and saves nothing:
import datetime
import json
from collections import Counter
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_expenses(expenses, path=LEDGER_FILE):
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, ensure_ascii=False, indent=2)
def add_expense(expenses, amount, category, note, date=None):
if amount <= 0:
raise ValueError("amount must be more than 0")
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
def total_by_category(expenses):
totals = Counter()
for e in expenses:
totals[e["category"]] = totals[e["category"]] + e["amount"]
return totals
def main():
expenses = load_expenses()
while True:
command = input("Command: ")
match command:
case "add":
amount = None
while amount is None:
try:
amount = int(input("Amount: "))
except ValueError:
print("Amount must be a whole number of birr.")
category = input("Category: ")
note = input("Note: ")
try:
add_expense(expenses, amount, category, note)
except ValueError as error:
print(f"Not added: {error}.")
else:
save_expenses(expenses)
print(f"Added {amount} birr for {category}.")
case "list":
if not expenses:
print("No expenses yet.")
for e in expenses:
print(e["date"], e["amount"], "birr", e["category"], e["note"])
case "summary":
for category, total in total_by_category(expenses).most_common():
print(f"{category}: {total} birr")
case "quit":
print("Goodbye!")
break
case _:
print("Unknown command. Try add, list, summary, or quit.")
if __name__ == "__main__":
main()In the editor, press Escape then Tab to move on.
One answer per line, given to input() in order.
Milestone 5: files
save_expenses and load_expenses take a path (lesson 8.9), so a test can give them a
file in tmp_path and never touch a real ledger.json.
መልመጃ
Round trips in tmp_path
This is the Milestone 4 file, and ledger.py now has the fix. Add a round trip of
sample_expenses, Amharic notes included, and a test that loading a missing file
gives [].
Your code
test_ledger.py
import pytest
from ledger import add_expense, total_by_category
@pytest.fixture
def sample_expenses():
expenses = []
add_expense(expenses, 120, "transport", "ታክሲ", "2026-09-28")
add_expense(expenses, 250, "food", "ምሳ", "2026-09-29")
add_expense(expenses, 60, "food", "ቡና", "2026-09-30")
return expenses
def test_add_expense(sample_expenses):
add_expense(sample_expenses, 40, "transport", "ባጃጅ", "2026-10-01")
assert len(sample_expenses) == 4
assert sample_expenses[-1] == {
"date": "2026-10-01",
"amount": 40,
"category": "transport",
"note": "ባጃጅ",
}
def test_total_by_category(sample_expenses):
assert total_by_category(sample_expenses) == {"food": 310, "transport": 120}
@pytest.mark.parametrize("category, expected", [
("food", 310),
("transport", 120),
("rent", 0),
])
def test_category_total(sample_expenses, category, expected):
assert total_by_category(sample_expenses)[category] == expected
def test_empty_ledger():
assert total_by_category([]) == {}
@pytest.mark.parametrize("amount", [0, -50])
def test_bad_amount(sample_expenses, amount):
with pytest.raises(ValueError):
add_expense(sample_expenses, amount, "food", "ቡና", "2026-10-01")
# Save sample_expenses to a file in tmp_path and load it back.
# Then: loading a file that doesn't exist.In the editor, press Escape then Tab to move on.
ledger.py
import datetime
import json
from collections import Counter
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_expenses(expenses, path=LEDGER_FILE):
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, ensure_ascii=False, indent=2)
def add_expense(expenses, amount, category, note, date=None):
if amount <= 0:
raise ValueError("amount must be more than 0")
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
def total_by_category(expenses):
totals = Counter()
for e in expenses:
totals[e["category"]] = totals[e["category"]] + e["amount"]
return totals
def main():
expenses = load_expenses()
while True:
command = input("Command: ")
match command:
case "add":
amount = None
while amount is None:
try:
amount = int(input("Amount: "))
except ValueError:
print("Amount must be a whole number of birr.")
category = input("Category: ")
note = input("Note: ")
try:
add_expense(expenses, amount, category, note)
except ValueError as error:
print(f"Not added: {error}.")
else:
save_expenses(expenses)
print(f"Added {amount} birr for {category}.")
case "list":
if not expenses:
print("No expenses yet.")
for e in expenses:
print(e["date"], e["amount"], "birr", e["category"], e["note"])
case "summary":
for category, total in total_by_category(expenses).most_common():
print(f"{category}: {total} birr")
case "quit":
print("Goodbye!")
break
case _:
print("Unknown command. Try add, list, summary, or quit.")
if __name__ == "__main__":
main()In the editor, press Escape then Tab to move on.
መፍትሄውን አሳይHide the solution
This is one way to solve it, not the only one. If yours prints the same thing, it works.
import pytest
from ledger import add_expense, load_expenses, save_expenses, total_by_category
@pytest.fixture
def sample_expenses():
expenses = []
add_expense(expenses, 120, "transport", "ታክሲ", "2026-09-28")
add_expense(expenses, 250, "food", "ምሳ", "2026-09-29")
add_expense(expenses, 60, "food", "ቡና", "2026-09-30")
return expenses
def test_add_expense(sample_expenses):
add_expense(sample_expenses, 40, "transport", "ባጃጅ", "2026-10-01")
assert len(sample_expenses) == 4
assert sample_expenses[-1] == {
"date": "2026-10-01",
"amount": 40,
"category": "transport",
"note": "ባጃጅ",
}
def test_total_by_category(sample_expenses):
assert total_by_category(sample_expenses) == {"food": 310, "transport": 120}
@pytest.mark.parametrize("category, expected", [
("food", 310),
("transport", 120),
("rent", 0),
])
def test_category_total(sample_expenses, category, expected):
assert total_by_category(sample_expenses)[category] == expected
def test_empty_ledger():
assert total_by_category([]) == {}
@pytest.mark.parametrize("amount", [0, -50])
def test_bad_amount(sample_expenses, amount):
with pytest.raises(ValueError):
add_expense(sample_expenses, amount, "food", "ቡና", "2026-10-01")
def test_save_and_load(tmp_path, sample_expenses):
path = tmp_path / "ledger.json"
save_expenses(sample_expenses, path)
assert load_expenses(path) == sample_expenses
def test_missing_file(tmp_path):
assert load_expenses(tmp_path / "ledger.json") == []Milestone 6: check the suite
Ten tests pass. Do they catch bugs? Below, ledger.py is the functions without
main(), which the tests never call, and three copies each have one planted bug. The
starting tests are the Milestone 5 file. Press Hunt.
Bug hunt
3 buggy versions of ledger.py are planted below. Good tests pass on the correct code and fail on every bug.
test_ledger.py
import pytest
from ledger import add_expense, load_expenses, save_expenses, total_by_category
@pytest.fixture
def sample_expenses():
expenses = []
add_expense(expenses, 120, "transport", "ታክሲ", "2026-09-28")
add_expense(expenses, 250, "food", "ምሳ", "2026-09-29")
add_expense(expenses, 60, "food", "ቡና", "2026-09-30")
return expenses
def test_add_expense(sample_expenses):
add_expense(sample_expenses, 40, "transport", "ባጃጅ", "2026-10-01")
assert len(sample_expenses) == 4
assert sample_expenses[-1] == {
"date": "2026-10-01",
"amount": 40,
"category": "transport",
"note": "ባጃጅ",
}
def test_total_by_category(sample_expenses):
assert total_by_category(sample_expenses) == {"food": 310, "transport": 120}
@pytest.mark.parametrize("category, expected", [
("food", 310),
("transport", 120),
("rent", 0),
])
def test_category_total(sample_expenses, category, expected):
assert total_by_category(sample_expenses)[category] == expected
def test_empty_ledger():
assert total_by_category([]) == {}
@pytest.mark.parametrize("amount", [0, -50])
def test_bad_amount(sample_expenses, amount):
with pytest.raises(ValueError):
add_expense(sample_expenses, amount, "food", "ቡና", "2026-10-01")
def test_save_and_load(tmp_path, sample_expenses):
path = tmp_path / "ledger.json"
save_expenses(sample_expenses, path)
assert load_expenses(path) == sample_expenses
def test_missing_file(tmp_path):
assert load_expenses(tmp_path / "ledger.json") == []In the editor, press Escape then Tab to move on.
ledger.py
import datetime
import json
from collections import Counter
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_expenses(expenses, path=LEDGER_FILE):
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, ensure_ascii=False, indent=2)
def add_expense(expenses, amount, category, note, date=None):
if amount <= 0:
raise ValueError("amount must be more than 0")
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
def total_by_category(expenses):
totals = Counter()
for e in expenses:
totals[e["category"]] = totals[e["category"]] + e["amount"]
return totalsPlanted bugs
- Totals skip the last expense
- Amharic is saved as escapes
- A missing file crashes
Show one answerHide the answer
One set of tests that catches every bug, not the only one.
import pytest
from ledger import add_expense, load_expenses, save_expenses, total_by_category
@pytest.fixture
def sample_expenses():
expenses = []
add_expense(expenses, 120, "transport", "ታክሲ", "2026-09-28")
add_expense(expenses, 250, "food", "ምሳ", "2026-09-29")
add_expense(expenses, 60, "food", "ቡና", "2026-09-30")
return expenses
def test_add_expense(sample_expenses):
add_expense(sample_expenses, 40, "transport", "ባጃጅ", "2026-10-01")
assert len(sample_expenses) == 4
assert sample_expenses[-1] == {
"date": "2026-10-01",
"amount": 40,
"category": "transport",
"note": "ባጃጅ",
}
def test_total_by_category(sample_expenses):
assert total_by_category(sample_expenses) == {"food": 310, "transport": 120}
@pytest.mark.parametrize("category, expected", [
("food", 310),
("transport", 120),
("rent", 0),
])
def test_category_total(sample_expenses, category, expected):
assert total_by_category(sample_expenses)[category] == expected
def test_empty_ledger():
assert total_by_category([]) == {}
@pytest.mark.parametrize("amount", [0, -50])
def test_bad_amount(sample_expenses, amount):
with pytest.raises(ValueError):
add_expense(sample_expenses, amount, "food", "ቡና", "2026-10-01")
def test_save_and_load(tmp_path, sample_expenses):
path = tmp_path / "ledger.json"
save_expenses(sample_expenses, path)
assert load_expenses(path) == sample_expenses
def test_missing_file(tmp_path):
assert load_expenses(tmp_path / "ledger.json") == []
def test_amharic_stays_readable(tmp_path, sample_expenses):
path = tmp_path / "ledger.json"
save_expenses(sample_expenses, path)
assert "ቡና" in path.read_text(encoding="utf-8")Caught 2 of 3. The round trip saved ቡና and loaded it back, so why did it miss the
escapes? Predict:
ውጤቱን ገምቱ
Decide before you look. Guessing wrong is how this sticks.
import json
text = json.dumps(["ቡና"])
print(json.loads(text) == ["ቡና"])
print("ቡና" in text)Python prints
True False
An escaped note loads back as the same text (lesson 8.4), so the round trip is equal either way. Only the file's text shows the escapes: it has no ቡና in it at all.
If you picked True twice, you trusted the round trip to see the file. It only sees what
comes back. To test how the file looks, read it: add a test that saves sample_expenses
to tmp_path and checks "ቡና" in path.read_text(encoding="utf-8") (lesson 8.2). Hunt
again: 3 of 3.
Run it for real
On a computer, the course's exercises repo has this project in
09-ledger-tests:
8.9's ledger.py and a test_ledger.py with Milestone 1. Open it in VS Code or a
Codespace
(lesson 8.8), and in a terminal in that folder run:
uv run pytestIts pyproject.toml lists pytest as a dev dependency: a package for working on the
project, not for running it. The first time, uv installs it into the folder's .venv
(lesson 8.8), then runs it. Without uv, run python -m pytest in an environment that has
pytest. pytest prints a header first, naming your Python and the folder (that's why the
browser uses -q), then the same dots and summary, and leaves a .pytest_cache folder
you can ignore. Build the suite milestone by milestone, and run it after every change to
ledger.py.
ዋና ዋና ነጥቦች
- Test the functions, not the menu: only
main()prints and asks, so everything else can be called with known values. - Fixed dates and
tmp_pathmake tests give the same result every day and never touch real data. - A failing test found a real bug in v1. Check the requirement, then fix the code where the rule belongs.
- A round trip can't see how a file looks. Check the suite with planted bugs; read the file when its text matters.
That's every lesson written so far
New lessons appear here as they're written, free like this one. The Telegram channel (opens in a new tab) announces each one.
Want video lessons, live sessions, and the community group too? See the paid Python course in Telegram።