Overview
A multiple-choice quiz is a small, self-contained way to see object-oriented programming solve a real problem instead of feeling like ceremony around a function. A question is not just a string — it is a prompt, a set of labeled choices, and a correct answer, all of which belong together, and asking one is behavior that belongs on the same object as that data. That is the whole case for a `Question` class: bundling `text`, `choices`, and `correct_answer` together, plus an `ask()` method that knows how to present itself, is more honest about what a question actually is than four separate parallel lists ever could be.
By the end of this tutorial you will have a console quiz built around two classes: `Question`, which represents one multiple-choice question and knows how to ask itself and check an answer, and `Quiz`, which owns a list of `Question` objects, runs them in order, and keeps score. Neither class needs to know anything about how the other is implemented internally — `Quiz` only ever calls `question.ask()` and `question.is_correct(answer)`, which is exactly the kind of clean boundary encapsulation is meant to produce.
- A `Question` class holding question text, a dict of lettered choices, and the correct answer.
- An `ask()` method that prints a question and its choices and returns the user's answer.
- An `is_correct()` method that checks an answer against the stored correct answer.
- A `Quiz` class that owns a list of `Question` objects and tracks a running score.
- A `run()` method that asks every question in order and a `show_results()` method that prints the final score.
Prerequisites
- OOP basics — defining a `class` with `__init__`, instance attributes (`self.x`), and methods.
- Dictionaries — modeling a question's lettered choices as `{"A": "...", "B": "...", ...}`.
- Conditionals — comparing the user's answer to the correct one and branching on the result.
- Loops — `for` with `enumerate()` to number questions as they are asked.
- String formatting — f-strings for printing questions, choices, and the final score.
Project Structure
The whole program lives in one file, `quiz.py`, with two classes doing all the work. `Question` is deliberately self-contained — it never touches `Quiz`, never knows how many other questions exist, and never tracks score. `Quiz` is the layer above it: it owns a `list` of `Question` objects passed in at construction time, loops over them one at a time in `run()`, and is the only place `score` is ever read or changed.
Each question's `choices` are stored as a `dict` mapping a letter key (`"A"`, `"B"`, `"C"`, `"D"`) to the choice text, rather than as a plain list. That choice matters: a dict preserves insertion order in modern Python, so choices always print in the order they were defined, and the same letter the user types back (`"A"`) is exactly the key used to check correctness — there is no separate step converting a list index into a letter or back.
Step 1: Define the Question Class
`ask()` prints the question and every choice, reads the user's answer, and normalizes it with `.strip().upper()` so that `"a"`, `"A"`, and `" a "` are all treated identically. `is_correct()` is intentionally a separate method from `ask()` rather than folded into it — `Quiz.run()` in Step 2 needs the raw answer for both checking correctness and, on a wrong answer, telling the user what the right one was.
class Question: """Represents one multiple-choice question: its text, its choices, and the correct answer."""
def __init__(self, text, choices, correct_answer): self.text = text # the question prompt shown to the user self.choices = choices # dict like {"A": "Paris", "B": "Rome", "C": "Berlin", "D": "Madrid"} self.correct_answer = correct_answer # the key (e.g. "A") that is correct, not the full answer text
def ask(self): """Print the question and its choices, then return the user's chosen letter.""" print(f"\n{self.text}") for letter, choice_text in self.choices.items(): # dict preserves insertion order, so choices print A, B, C, D in order print(f" {letter}. {choice_text}") answer = input("Your answer: ").strip().upper() # normalize so "a" and " a " both count as "A" return answer
def is_correct(self, answer): """Return True if the given answer letter matches this question's correct answer.""" return answer == self.correct_answerStep 2: Define the Quiz Class
`Quiz.__init__` stores the list of questions it was given and starts `score` at zero — `Quiz` never builds its own questions, it is only ever handed a ready-made list, which keeps question content and quiz-running logic cleanly separated. `run()` is where the two classes actually meet: it calls `question.ask()` to get an answer and `question.is_correct(answer)` to grade it, without ever looking inside `Question` at its `choices` dict directly.
class Quiz: """Owns a list of Question objects, runs them in order, and tracks the score."""
def __init__(self, questions): self.questions = questions # list of Question objects, built by the caller in Step 4 self.score = 0 # Quiz is the only class that ever reads or changes this
def run(self): """Ask every question in order, updating self.score as the user answers.""" for i, question in enumerate(self.questions, start=1): # start=1 so questions are announced "1 of 4", not "0 of 4" print(f"\nQuestion {i} of {len(self.questions)}") answer = question.ask() if question.is_correct(answer): print("Correct!") self.score += 1 else: print(f"Incorrect. The correct answer was {question.correct_answer}.")Click Run to see what this code prints.
Step 3: Show Final Results
`show_results()` is added as another method on `Quiz`, called once after `run()` finishes. The `if total > 0 else 0` guard exists so an empty quiz (an empty `questions` list) prints a score of 0% instead of crashing on a division by zero — a small edge case, but one that costs nothing to handle correctly.
def show_results(self): """Print the final score as a raw count and a percentage.""" total = len(self.questions) percentage = (self.score / total) * 100 if total > 0 else 0 # guards against dividing by zero on an empty quiz print(f"\nYou scored {self.score} out of {total} ({percentage:.1f}%)")This method is indented at the same level as `__init__` and `run()` above it, meaning it belongs inside the `Quiz` class body — the Complete Code section below shows the full class with all three methods assembled together in one place.
Step 4: Build the Question Bank
`build_sample_questions()` is a plain function, not a method on either class, because building the specific set of questions for this quiz is a separate concern from how questions or quizzes behave in general — swapping in an entirely different question bank later means replacing this one function, and nothing else in the program needs to change.
def build_sample_questions(): """Return the list of Question objects that make up this quiz's question bank.""" return [ Question( "What does OOP stand for?", {"A": "Object-Oriented Programming", "B": "Only One Program", "C": "Output Of Process", "D": "Ordered Object Pattern"}, "A", ), Question( "Which keyword defines a class in Python?", {"A": "function", "B": "class", "C": "def", "D": "object"}, "B", ), Question( "What data type is {'A': 'Paris'}?", {"A": "list", "B": "tuple", "C": "dict", "D": "set"}, "C", ), Question( "Which special method is automatically called when you create a new object?", {"A": "__init__", "B": "__main__", "C": "__new__", "D": "__create__"}, "A", ), ]Step 5: Build the Entry Point
`main()` is short by design: build the questions, construct a `Quiz` around them, run it, and show the results. Every interesting decision — how a question asks itself, how an answer is graded, how the score is tallied — already lives inside `Question` and `Quiz`, which is the payoff of building those classes carefully in the earlier steps.
def main(): quiz = Quiz(build_sample_questions()) quiz.run() quiz.show_results()
if __name__ == "__main__": main()Complete Code
Here is the full program with both classes assembled, ready to run with `python quiz.py`.
class Question: """Represents one multiple-choice question: its text, its choices, and the correct answer."""
def __init__(self, text, choices, correct_answer): self.text = text self.choices = choices self.correct_answer = correct_answer
def ask(self): """Print the question and its choices, then return the user's chosen letter.""" print(f"\n{self.text}") for letter, choice_text in self.choices.items(): print(f" {letter}. {choice_text}") answer = input("Your answer: ").strip().upper() return answer
def is_correct(self, answer): """Return True if the given answer letter matches this question's correct answer.""" return answer == self.correct_answer
class Quiz: """Owns a list of Question objects, runs them in order, and tracks the score."""
def __init__(self, questions): self.questions = questions self.score = 0
def run(self): """Ask every question in order, updating self.score as the user answers.""" for i, question in enumerate(self.questions, start=1): print(f"\nQuestion {i} of {len(self.questions)}") answer = question.ask() if question.is_correct(answer): print("Correct!") self.score += 1 else: print(f"Incorrect. The correct answer was {question.correct_answer}.")
def show_results(self): """Print the final score as a raw count and a percentage.""" total = len(self.questions) percentage = (self.score / total) * 100 if total > 0 else 0 print(f"\nYou scored {self.score} out of {total} ({percentage:.1f}%)")
def build_sample_questions(): """Return the list of Question objects that make up this quiz's question bank.""" return [ Question( "What does OOP stand for?", {"A": "Object-Oriented Programming", "B": "Only One Program", "C": "Output Of Process", "D": "Ordered Object Pattern"}, "A", ), Question( "Which keyword defines a class in Python?", {"A": "function", "B": "class", "C": "def", "D": "object"}, "B", ), Question( "What data type is {'A': 'Paris'}?", {"A": "list", "B": "tuple", "C": "dict", "D": "set"}, "C", ), Question( "Which special method is automatically called when you create a new object?", {"A": "__init__", "B": "__main__", "C": "__new__", "D": "__create__"}, "A", ), ]
def main(): quiz = Quiz(build_sample_questions()) quiz.run() quiz.show_results()
if __name__ == "__main__": main()Sample Run
Click Run to see what this code prints.
Extend This Project
- Load questions from a JSON file instead of hardcoding them, reusing the load/save pattern from the Todo List project.
- Add a `Timer` that limits each question to a set number of seconds using the `time` module.
- Add difficulty levels by giving `Question` a `points` attribute so harder questions are worth more.
- Shuffle both the question order and each question's choices with `random.shuffle()` before each run.
- Track high scores across multiple runs by saving each `show_results()` outcome to a small history file.
Summary
You built a quiz game where `Question` and `Quiz` each own exactly one responsibility: a `Question` knows how to present itself and grade one answer, and a `Quiz` knows how to run a list of them and keep score, without either class reaching into the other's internal data. That division of responsibility is the real lesson OOP is trying to teach beyond syntax — once a class's data and the behavior that uses it live together, the rest of the program only ever needs to know what a class can do, never how it does it.