Lesson 10 of 10
Time to build something with a safety net. This project is a command-line to-do manager that saves to JSON — and, more importantly, a test suite that proves it works.
You already built projects that store data (Module 6's expense tracker). The difference here is that when you change this one, the tests tell you instantly whether you broke it.
What we're building
- Add tasks with a title and a priority
- Mark tasks complete, remove them, list them
- Save to and load from a JSON file, surviving a corrupt file
- A
unittestsuite covering the happy path, the edges, and the errors
Step 1: the data model
Everything the app can do lives in one class. No printing, no input() — just behaviour that
returns values and raises errors. That separation is exactly what makes it testable.
Three design choices worth naming:
_next_idis saved too. Without it, reloading and adding a task would reuse an old id. There's a test for exactly this below.- A missing file loads as empty; a corrupt file raises. "No tasks yet" is normal. "Your data is unreadable" is not — silently starting fresh would destroy someone's list.
- One custom exception,
TaskError. Callers catch one thing, whatever went wrong.
Step 2: the tests
Now the part that makes this a Module 9 project. In a real repo the class lives in todo.py
and the tests start with from todo import TodoList, TaskError — here we paste the class in so
the block runs standalone.
setUp gives every test a fresh temp directory, so no test can see another's file.
Twelve tests, grouped by what they're for: the happy path, the error paths, persistence. Notice
what's not tested — json.dump works, Path.exists works. Test your logic, not the standard
library.
Break something on purpose: change self._next_id += 1 to self._next_id += 2 and re-run.
test_add_assigns_incrementing_ids and test_ids_keep_counting_after_reload go red and name
themselves. That's the whole payoff.
Step 3: the CLI
The interface layer is thin on purpose — read input, call a method, print the result, catch
TaskError. All the thinking already happened in the class.
Try it: add a couple of tasks, complete one, list them, then save and quit. Ask for id 99 and
watch it print an error instead of crashing.
Note main() never lets an exception escape — it catches TaskError from the list and
ValueError from int(). A CLI that shows a traceback to a user is a CLI with a bug.
Take it further
Each of these is a change plus the test that proves it:
- Due dates. Store an ISO date string, add
overdue()usingdatetime(lesson 9.6). Test it with a fixed date, neverdate.today()— a test that changes answer by the day is worse than no test. - Search.
find_by_text(query), case-insensitive. Test: a match, a non-match, mixed case. - Sorting.
sorted(tasks, key=...)by priority then id (lesson 4.7). - Argparse. Swap the menu for real arguments —
todo add "buy milk" --priority high. Theargparsemodule is in the standard library. - Atomic saves. Write to
todo.json.tmpthenos.replaceit (lesson 8.7), so a crash mid-write can't corrupt the file.
Module 9 complete
You can now write comprehensions that stay readable, generators that stream instead of materialise, decorators that wrap behaviour, and context managers that clean up after themselves. You handle dates without off-by-one bugs, pull structure out of text with regex, write code other Python programmers recognise on sight — and prove it works with tests.
That's the intermediate arc. Next up: Module 10 — Capstone Projects, where you pick something real and ship it.
Questions about this lesson
Log in to ask a question.