BeginnerGentle

CLI Quiz or Habit Tracker

Build a small, menu-driven command-line program — a quiz game or a habit tracker, your choice — that validates input, saves its state to disk between runs, and is covered by your own pytest tests.

Skills you’ll practice

  • control flow
  • functions
  • collections
  • input validation
  • file persistence
  • testing

This is the Beginner tier’s first project: a chance to combine control flow, functions, collections, validation, file persistence, and testing into one working program, instead of practicing each in isolation. There is no starter repository and no single correct file layout — the requirements below describe the behavior your program must have; how you organize the code to get there is your decision.

Choose your scenario

Both options exercise the same skills. Pick whichever sounds more useful or more interesting to you — the requirements, fixtures, and acceptance checklist below apply to either.

Option A: CLI quiz

A terminal quiz game that loads a bank of questions, asks them in a menu-driven loop, tracks the player’s score, and saves high scores to disk so they survive between runs.

Option B: habit tracker

A terminal habit tracker that loads a list of habits, lets the user mark each one done for the day from a menu, and persists completion history to disk so streaks survive between runs.

Requirements

Stage 1: load and display

  • Read a starter set of items (questions, or habits) from a JSON file using pathlib and the json module — see Fixtures below for the shape to expect.
  • Print a main menu with at least three options (for example: take the quiz / view high scores / quit, or: mark a habit done / view streaks / quit) and loop until the user chooses to quit.
  • Validate menu input: a non-numeric entry, an out-of-range number, or a blank line should print a clear message and re-prompt, not crash the program.

Stage 2: the core loop

  • Quiz option: ask every question in the bank once per session, in a fixed or shuffled order (your choice), accept an answer, and report whether it was correct before moving to the next question. Track and display the running score.
  • Habit option: for each habit, let the user mark today as done or not done. Track, per habit, the current streak (consecutive days done) and the longest streak seen so far.
  • Validate the user’s answer/input at each step the same way as Stage 1 — no uncaught exception should ever end the program early because of something the user typed.

Stage 3: persistence

  • Save the result of a session (final score and the date, or updated streak data) to a JSON file on disk when the user quits.
  • On startup, load any existing saved data if the file exists, and start fresh (without crashing) if it doesn’t.
  • Use a with block for every file open, and keep the save file’s path constructed with pathlib.Path, not string concatenation.

Stage 4: tests

  • Write pytest tests for at least: one validation function (rejecting bad menu input), one piece of scoring/streak logic, and the save/load round trip (write data, read it back, assert it matches).
  • The save/load test must not depend on any file left over from a previous run — use a temporary directory (pytest’s built-in tmp_path fixture is the standard tool for this) so the test is deterministic no matter what ran before it.

Fixtures

Starter data for whichever option you pick. Neither needs to be copied verbatim — extend it once your program can read the shape below.

Outputquestions.json (quiz option)
[
  {
    "prompt": "What keyword defines a function in Python?",
    "answer": "def"
  },
  {
    "prompt": "What does len([1, 2, 3]) return?",
    "answer": "3"
  },
  {
    "prompt": "What exception does dict['missing'] raise for a missing key?",
    "answer": "KeyError"
  }
]
Outputhabits.json (habit tracker option)
[{ "name": "Read for 20 minutes" }, { "name": "Stretch" }, { "name": "Write one paragraph" }]

Acceptance checklist

Your project is done when all of the following hold:

  • The program runs from the terminal with a single command and shows a menu without crashing.
  • Every user input point (menu choice, answer, yes/no) rejects invalid input with a message and a re-prompt, instead of raising an uncaught exception.
  • Session results (score, or streak data) are saved to a JSON file on quit.
  • Restarting the program loads the previously saved data correctly.
  • Deleting the save file and restarting doesn’t crash — it starts fresh instead.
  • pytest passes on at least three tests: one validation case, one scoring/streak case, and one save/load round trip using tmp_path.
  • ruff format --check and ruff check both pass on your source files.

Common places to get stuck

  • Validating input inside the same function that reads it, then never reusing that function — if three different menus each re-implement “read a number, re-prompt if invalid,” a bug fixed in one place stays broken in the other two. Write one small validation function and call it from everywhere input is read, the same lesson Functions and Scope makes about decomposing a long script.
  • Losing state between menu loop iterations — if score or streak data is reset by a variable declared inside the loop body instead of outside it, every “correct answer” or “habit marked done” silently vanishes on the next iteration.
  • Writing the save file before validating what’s being written — a partially-built or malformed data structure written to disk will still load “successfully” next time, just with wrong values that are hard to trace back to their cause.

Stuck? Reveal one hint at a time.

  1. Hint 1

    If your score or streak count resets every time through the menu loop, check where the variable holding it is initialized — inside the loop body re-creates it every iteration; it needs to live outside the loop and only be updated inside it.

  2. Hint 2

    For the save/load test, write the data with your save function, then immediately read it back with your load function in the same test, asserting the two are equal — don't hand-write the expected JSON string; let your own load function be the thing that interprets what your own save function wrote.

  3. Hint 3

    For invalid menu input, the loop shape that works reliably is: read input inside a while True: loop, try to convert/validate it, and only break out of the loop once it passes — printing a message and continuing (not returning or crashing) on each failure.

Extension ideas

Once the acceptance checklist passes, any of these extend the project without requiring anything past what the Beginner tier has covered:

  • Quiz: categorize questions by topic and let the user pick a category before starting.
  • Habit tracker: add a weekly summary view that reports completion rate per habit.
  • Either: support multiple named save files (one per user), and validate the filename the user types before using it to build a path.
  • Either: add a --reset style option that clears saved data after an explicit yes/no confirmation, so an accidental reset isn’t one keystroke away.

Debugging journal

Before moving on, write down (a paragraph is enough): one bug you introduced while building this that didn’t crash the program — it produced a wrong-but-plausible result instead. How did you notice it, and which of your acceptance-checklist tests would have caught it sooner if it had existed first? Errors and Debugging draws exactly this distinction between a crash and a silently wrong result — this project is where that distinction stops being hypothetical.

See one reference approach (not the only correct one)

Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.

There’s more than one reasonable way to structure this program. One approach that satisfies every stage above:

Pythonstorage.py — persistence, isolated from the menu loop
import json
from pathlib import Path


def load_data(path: Path, default: dict) -> dict:
    if not path.exists():
        return default
    with path.open(encoding="utf-8") as f:
        return json.load(f)


def save_data(path: Path, data: dict) -> None:
    with path.open("w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)
Pythontest_storage.py
from pathlib import Path

from storage import load_data, save_data


def test_load_data_missing_file_returns_default(tmp_path: Path) -> None:
    missing = tmp_path / "save.json"
    assert load_data(missing, default={"score": 0}) == {"score": 0}


def test_save_then_load_round_trip(tmp_path: Path) -> None:
    path = tmp_path / "save.json"
    save_data(path, {"score": 7})

    assert load_data(path, default={}) == {"score": 7}

The key design choice here is keeping load_data/save_data free of any input() calls or menu logic — that’s what makes test_save_then_load_round_trip possible to write at all: a function that mixes reading user input with reading/writing files can’t be tested without faking a terminal. Isolating the parts of a program that do I/O to a file from the parts that talk to a user pays for itself the moment you try to write the first test.

Rubric

Dimension What it’s assessing
Correctness Every acceptance-checklist item passes; scoring/streak logic matches its stated rules.
Clarity Names describe what they hold; validation logic isn’t duplicated across menus.
Resilience Invalid input, a missing save file, and a malformed save file all fail gracefully.
Usability Menu text is unambiguous; the user always knows what just happened and what to do next.

Next step

Once this is checked off, Testing and Quality is where the tier’s testing practice was taught in full — revisit it if any of Stage 4’s tests felt unfamiliar. From there, the Expense Analyzer and Reusable Package and CLI build on the same file-persistence and validation habits this project practices.