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
Questionclass 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
Quizclass that owns a list ofQuestionobjects and tracks a running score. - A
run()method that asks every question in order and ashow_results()method that prints the final score.
Prerequisites
- OOP basics — defining a
classwith__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 —
forwithenumerate()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
Timerthat limits each question to a set number of seconds using thetimemodule. - Add difficulty levels by giving
Questionapointsattribute 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.