Test-Drive a Grade Calculator with pytest
Build a Python grade calculator using Test-Driven Development with pytest.
Introduction
30 Second Summary
You know that feeling when you submit code and just hope nothing breaks? Professional developers replace that anxiety with automated tests that prove their code works before it ever ships.
In this project, you will build a Python grade calculator using Test-Driven Development with pytest. You'll write tests first, watch them fail, then write just enough code to make them pass.
What You'll Build
Run pytest -v in your terminal and watch a column of green checkmarks scroll by, each one proving a different function in your grade calculator handles its inputs correctly.
By the end of this project, you'll have:
- A test suite you run with one command to see green checkmarks proving every function works. Introduce a bug on purpose and watch the tests catch it instantly.
- Boundary value tests that prove your calculator handles the tricky edges (exactly 50%? 0? 100?) without needing to test every possible number.
- Parametrized tests that run one test function against dozens of inputs automatically. One decorator replaces a dozen repetitive test functions.
- Secret Mission: Add integration tests that verify your modules work together correctly.
Are there any prerequisites?
This project assumes basic Python knowledge: functions, conditionals, and lists. No prior testing experience is needed.
Before We Start
Before you can write a single test, you need the right testing tool installed and a clean project structure in place.
In this step, you will install pytest (the Python testing framework) and create the two files that will hold your grade calculator and its tests.
In this step, get ready to:
- Install pytest and verify it works.
- Create the project folder and files.
- Confirm pytest can discover your test file.
Install pytest
pytest is the most popular Python testing framework. It uses plain assert statements and gives you detailed failure reports when something breaks.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (backtick key, top-left of your keyboard). This works on both macOS and Windows.
- Install pytest by running this command:
pip install -U pytest
What does this command do?
pip install -U pytest tells Python's package manager to install (or upgrade to) the latest version of pytest.
- Verify the installation by running:
pytest --version
You should see a version number like pytest 9.1.1 (or similar) printed in the terminal.
Seeing 'command not found'?
On Windows, try running py -m pip install -U pytest instead. This uses the Python launcher to find pip.
If you still get an error, confirm Python is in your system PATH by running python --version. If that also fails, reinstall Python and check the 'Add to PATH' box during installation.
help me fix a 'command not found' error when running pip install
Create the project structure
You need a dedicated folder for this project with two Python files: one for your grade calculator code, and one for your tests.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir grade-calculator
cd grade-calculator
- Create the two project files by running the command for your operating system:
macOS
touch grade_calculator.py test_grade_calculator.py
Windows
New-Item -Path grade_calculator.py -ItemType File
New-Item -Path test_grade_calculator.py -ItemType File
What are these files for?
grade_calculator.py is where you will write your grade calculator code. It starts empty because you will write tests first, then code.
test_grade_calculator.py is where your tests live. pytest automatically discovers files that start with test_ so this naming is important.
- Confirm the files exist by running:
ls
You should see grade_calculator.py and test_grade_calculator.py in the output.
Don't see both files?
Make sure you ran the file creation commands from inside the grade-calculator folder. Run pwd to check your current location. The path should end with grade-calculator.
help me figure out why my files weren't created in the right folder
Verify pytest discovers your test file
Now let's confirm that pytest can find your test file. pytest uses a convention called test discovery. It automatically searches for files matching the pattern test_*.py.
- Open the grade-calculator folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the grade-calculator folder, then click Open.
- Open a new terminal in VS Code by pressing Ctrl+`. This terminal starts inside your project folder automatically.
Before you run the next command, what do you think pytest will report? There are no tests written yet.
- Run pytest from your project folder:
pytest
You should see output that includes collected 0 items and no tests ran. This confirms pytest found your test file but there are no test functions inside it yet.
Seeing an error instead of 'collected 0 items'?
Make sure your terminal's working directory is the grade-calculator folder. If you opened the folder correctly in VS Code, the terminal prompt should show grade-calculator in the path.
Also confirm your test file is named exactly test_grade_calculator.py (starting with test_). pytest ignores files that don't match this pattern.
help me debug why pytest isn't discovering my test file
✔️ Awesome, I've got everything!
Great. Double check you have both files saved in your grade-calculator folder and that pytest shows collected 0 items when you run it.
ⓧ I'd like to double check the full code
Both files should be completely empty at this point. If ls shows both grade_calculator.py and test_grade_calculator.py in the output, and pytest shows collected 0 items, you are all set.
Your testing environment is ready. Next up, you will write your very first test for a function that does not exist yet.
Write Your First Failing Test
Your environment is ready. Now comes the part that feels backwards: you're going to write a test for code that doesn't exist yet.
This is how Test-Driven Development works. You define what your code should do first, watch the test fail, then write just enough to make it pass.
In this step, get ready to:
- Write a test for a function that doesn't exist yet.
- Run the test and watch it fail (the Red phase).
- Write just enough code to make it pass (the Green phase).
Write a test for a function that does not exist yet
In TDD, tests come first. You describe the behaviour you want, then make it real. Right now grade_calculator.py is empty, and that's fine.
- Open test_grade_calculator.py in VS Code and add the following test code:
# Import the function we want to test (it doesn't exist yet)
from grade_calculator import calculate_grade
def test_score_90_returns_grade_A():
# A score of 90 should return an "A" grade
assert calculate_grade(90) == "A"
What does this code do?
- from grade_calculator import calculate_grade imports a function from your other file. That function doesn't exist yet, which is the point.
- def test_score_90_returns_grade_A() defines a test function. pytest automatically discovers functions that start with test_.
- assert calculate_grade(90) == "A" is the test assertion. If calculate_grade(90) returns anything other than "A", the test fails.
- Save test_grade_calculator.py.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
from grade_calculator import calculate_grade
def test_score_90_returns_grade_A():
assert calculate_grade(90) == "A"
Run pytest and observe the failure
Before you run this, what do you think will happen? The test imports a function from an empty file.
- Run your test suite by running this command:
pytest
You see a FAILED result with red text. The error is an ImportError because calculate_grade does not exist in grade_calculator.py yet.
This is the Red phase
The failure is intentional. In the Red-Green-Refactor cycle, Red means your test defines behaviour that your code cannot satisfy yet. The test is your specification. The failure is your to-do list.
Write the minimum code to pass
The Green phase means writing the smallest possible code that makes the test pass. You are not building the full grading logic yet.
- Open grade_calculator.py and add the following code:
def calculate_grade(score):
# Hardcoded return value - just enough to pass the current test
return "A"
Why does this only return A?
In TDD, you only write enough code to satisfy your existing tests. The only test right now checks that a score of 90 returns "A". So returning "A" unconditionally is correct for now.
This naive implementation will break the moment you add a test for a different grade. That's the whole point. More tests force better code.
- Save grade_calculator.py.
What do you think will happen when you run pytest now? You wrote the function the test was looking for.
- Run the test suite again by running:
pytest
You see 1 test passed with green output. That's the Green phase complete.
Still seeing a failure?
- Make sure you saved both files before running pytest.
- Check that your function is named exactly calculate_grade (not calculateGrade or calc_grade). The import must match the function name.
- Confirm you are running pytest from inside the grade-calculator/ folder where both files live.
Help me debug my failing pytest test.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files.
ⓧ I'd like to double check the full code
def calculate_grade(score):
return "A"
Your first test passes with a hardcoded return value. That's one Red-Green cycle complete. Next up, you'll add more tests that force the implementation to handle all grade boundaries.
Expand Tests with Boundary Values
You wrote your first failing test, made it pass, and experienced the TDD cycle. Your calculate_grade function passes its one test case.
One passing test does not prove your code works. Your current implementation returns "A" for every score, so a single test gives false confidence. You need tests at the boundaries between grades to force a real implementation.
In this step, get ready to:
- Add boundary value tests for all grade thresholds.
- Observe what happens when your naive implementation faces multiple tests.
- Write the full grading logic and see all tests turn green.
Add tests for all grade boundaries
Boundary value testing means picking inputs at the exact edges where behaviour changes. For grades, those edges are 90, 80, 70, and 60. Testing just above and just below each boundary catches off-by-one errors without testing every number from 0 to 100.
- In test_grade_calculator.py, add these test functions below your existing test_score_90_returns_grade_A function:
# Boundary tests: one for each grade threshold and just-below cases
def test_score_80_returns_grade_B():
assert calculate_grade(80) == "B"
def test_score_70_returns_grade_C():
assert calculate_grade(70) == "C"
def test_score_60_returns_grade_D():
assert calculate_grade(60) == "D"
def test_score_50_returns_grade_F():
assert calculate_grade(50) == "F"
def test_score_89_returns_grade_B():
assert calculate_grade(89) == "B"
def test_score_79_returns_grade_C():
assert calculate_grade(79) == "C"
What do these tests cover?
Each function tests one boundary between two letter grades. Together with your original test, you now have 7 tests covering all 5 grade letters.
- test_score_89_returns_grade_B tests the score just below the "A" threshold. If your code returns "A" for 89, you have an off-by-one bug.
- test_score_50_returns_grade_F tests a clearly failing score well below any passing boundary.
- This strategy comes from the testing principle that exhaustive testing is impossible. You pick 7 strategic values instead of testing all 101 possible integer scores.
- Save test_grade_calculator.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
from grade_calculator import calculate_grade
def test_score_90_returns_grade_A():
assert calculate_grade(90) == "A"
def test_score_80_returns_grade_B():
assert calculate_grade(80) == "B"
def test_score_70_returns_grade_C():
assert calculate_grade(70) == "C"
def test_score_60_returns_grade_D():
assert calculate_grade(60) == "D"
def test_score_50_returns_grade_F():
assert calculate_grade(50) == "F"
def test_score_89_returns_grade_B():
assert calculate_grade(89) == "B"
def test_score_79_returns_grade_C():
assert calculate_grade(79) == "C"
Run pytest and observe the failures
Before you run pytest, think about what you expect. Your implementation currently returns "A" for every input. How many of your 7 tests do you think will pass?
- Run your test suite:
pytest
You see 6 tests FAILED and only 1 passed. The only test that succeeds is test_score_90_returns_grade_A because your naive implementation happens to return the right answer for that one case. Every other test expects a different letter grade and gets "A" instead.
This is the Red phase. Your tests exposed that a hardcoded return value cannot satisfy a real grading system.
Why is this failure a good thing?
Your tests are doing their job. One passing test gave you false confidence. Six failures prove the implementation is incomplete.
This is the core loop of Test-Driven Development: write tests that define the behaviour you want, watch them fail, then write just enough code to make them pass.
Seeing 0 tests collected?
- Make sure your file is named test_grade_calculator.py with the test_ prefix. Pytest discovers files matching this pattern automatically.
- Confirm you saved the file after adding the new functions.
- Check that each function name starts with test_. Pytest skips functions that do not follow this naming convention.
Why is pytest not finding my test functions?
Write the real grading logic
Your tests define exactly what calculate_grade should do for each score range. Now write just enough code to make all 7 tests pass.
- In grade_calculator.py, replace the entire contents of the file with the full implementation:
def calculate_grade(score):
# Check each grade threshold from highest to lowest
if score >= 90:
return "A"
elif score >= 80:
return "B"
elif score >= 70:
return "C"
elif score >= 60:
return "D"
else:
return "F"
What does this code do?
- The if/elif/else chain checks the score against each threshold from highest (90) to lowest (60).
- As soon as a condition matches, the function returns the corresponding grade letter and stops.
- The order matters. Checking >= 90 first ensures that a score of 95 returns "A" and does not fall through to "B".
- The else clause catches every score below 60, returning "F".
- Save grade_calculator.py.
Before you run pytest again, what do you expect? Your implementation now has logic for each grade range, and your 7 tests each target a specific boundary.
- Run your test suite to confirm:
pytest
All 7 tests pass. Your terminal shows green output confirming every boundary value gets the correct grade. This is the Green phase.
Still seeing failures?
- Check that you replaced the entire file contents, not just added below the old return "A" line.
- Make sure the elif and else keywords are indented at the same level as if.
- Confirm you saved the file after making the change.
Why are my boundary value tests still failing after I updated the implementation?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
def calculate_grade(score):
if score >= 90:
return "A"
elif score >= 80:
return "B"
elif score >= 70:
return "C"
elif score >= 60:
return "D"
else:
return "F"
Your test suite now covers every grade boundary and forces your implementation to handle each one. Next up, you'll use parametrized tests to run dozens of inputs with a single function and handle invalid data like negative scores.
Parametrize Tests and Handle Edge Cases
All seven of your boundary tests pass. Your calculate_grade function handles every valid score correctly.
But look at your test file. Seven nearly identical functions, each testing one number. What if you needed to test 50 inputs? Writing one test function per input does not scale. And what happens when someone passes -5 or "ninety" to your function? Right now, it silently returns a grade instead of flagging the bad input.
In this step, get ready to:
- Replace repetitive test functions with a single parametrized test.
- Add tests for invalid inputs (edge cases).
- Add input validation to your implementation.
Replace repetitive tests with a parametrized test
Pytest's @pytest.mark.parametrize decorator lets you run a single test function against many inputs automatically. Instead of seven functions that look almost identical, you write one function and hand it a list of test cases.
- Open test_grade_calculator.py in VS Code.
- Replace the first line from grade_calculator import calculate_grade with these two import lines:
import pytest
from grade_calculator import calculate_grade
- Delete all six test functions EXCEPT test_score_90_returns_grade_A. Keep that first test as a simple sanity check.
- Below test_score_90_returns_grade_A, add this parametrized test that covers 12 score/grade pairs in one function:
@pytest.mark.parametrize("score,expected_grade", [
(100, "A"),
(95, "A"),
(90, "A"),
(89, "B"),
(80, "B"),
(79, "C"),
(70, "C"),
(69, "D"),
(60, "D"),
(59, "F"),
(50, "F"),
(0, "F"),
])
def test_grade_boundaries(score, expected_grade):
assert calculate_grade(score) == expected_grade
What does this code do?
The @pytest.mark.parametrize decorator takes two arguments: a string naming the parameters ("score,expected_grade") and a list of tuples containing the test values.
Pytest runs test_grade_boundaries once for each tuple. One function, 12 test cases. Each boundary between two grades is tested from both sides (89 vs 90, 79 vs 80).
- Save test_grade_calculator.py.
- Run the tests by running this command:
pytest
You should see 13 tests pass: your original test_score_90_returns_grade_A plus 12 parametrized cases.
Seeing import errors or 0 tests collected?
- Make sure import pytest is at the very top of your file, before the from grade_calculator line.
- Check that the decorator @pytest.mark.parametrize sits directly above def test_grade_boundaries with no blank lines between them.
- Verify the parameter string uses a comma with no spaces: "score,expected_grade".
Why aren't my parametrized tests being discovered?
Why not test every number from 0 to 100?
This connects to the testing principle "exhaustive testing is impossible." You cannot test every possible input. Instead, you strategically pick boundary values where behaviour changes (89 vs 90, 59 vs 60). If those work, the values between them will too.
Add tests for invalid inputs
Your function handles scores 0 through 100 correctly. But what if someone passes a negative number, a score over 100, or a string? These are edge cases where bugs tend to cluster.
- Add these three tests at the bottom of test_grade_calculator.py:
def test_negative_score_raises_error():
with pytest.raises(ValueError):
calculate_grade(-1)
def test_score_over_100_raises_error():
with pytest.raises(ValueError):
calculate_grade(101)
def test_non_numeric_score_raises_error():
with pytest.raises(TypeError):
calculate_grade("ninety")
What does pytest.raises do?
The with pytest.raises(ValueError): block is how you test that code raises an exception. The test passes only if the code inside the block raises that exact exception type. If the code runs without error, the test fails.
- Save test_grade_calculator.py.
Before you run the tests, think about what will happen. Your calculate_grade function has no validation code. Will these new tests pass or fail?
- Run pytest to find out:
pytest
You see 3 FAILED tests. The invalid input tests fail because your current implementation does not validate inputs. Passing -1 just returns "F" instead of raising an error. This is the Red phase again. Your tests define what SHOULD happen. Now you need to make it happen.
All tests passing instead of 3 failures?
If you see all tests passing, you may have accidentally added validation already. Check that grade_calculator.py still has only the if/elif/else grading logic with no raise statements. The 3 new tests SHOULD fail at this point.
Why are my invalid input tests not failing?
Add input validation to the implementation
Your tests defined the behaviour you want. Now write just enough code to make them pass. You need to check for non-numeric types and out-of-range values before the grading logic runs.
- Open grade_calculator.py. Find the first line of the function:
def calculate_grade(score):
if score >= 90:
- Add these validation checks between def calculate_grade(score): and if score >= 90::
if not isinstance(score, (int, float)):
raise TypeError("Score must be a number")
if score < 0 or score > 100:
raise ValueError("Score must be between 0 and 100")
What does this validation do?
- isinstance(score, (int, float)) checks if the input is a number. If not, it raises a TypeError.
- The range check ensures the score is between 0 and 100. If it is outside that range, it raises a ValueError.
- Both checks run BEFORE the grading logic, so invalid inputs never reach the if/elif chain.
- Save grade_calculator.py.
Before you run the tests, predict: will all 16 tests pass now, or will some still fail?
- Run the full test suite in verbose mode by running this command:
pytest -v
All 16 tests pass. In the verbose output, you can see each parametrized case listed with its inputs, like test_grade_boundaries[90-A] and test_grade_boundaries[59-F]. That is the power of parametrize: clear, labelled test cases from a single function.
Still seeing failures on the invalid input tests?
- Make sure your validation lines are indented inside the function (4 spaces), directly below def calculate_grade(score): and above if score >= 90:.
- Check the error message strings match exactly: "Score must be a number" for the TypeError and "Score must be between 0 and 100" for the ValueError.
- Verify the type check uses (int, float) as a tuple, not just int.
Help me fix my input validation in grade_calculator.py
Why do bugs cluster around edge cases?
This demonstrates the testing principle "defects cluster together." Invalid input handling is one of the most common places bugs hide. Code that handles the happy path (valid scores) is straightforward. Code that guards against unexpected inputs is where developers forget to add checks or get the logic wrong.
✔️ Awesome, I've got everything!
Great. Make sure you have saved both test_grade_calculator.py and grade_calculator.py.
ⓧ I'd like to double check the full code
Here is what test_grade_calculator.py should look like at this point:
import pytest
from grade_calculator import calculate_grade
def test_score_90_returns_grade_A():
assert calculate_grade(90) == "A"
@pytest.mark.parametrize("score,expected_grade", [
(100, "A"),
(95, "A"),
(90, "A"),
(89, "B"),
(80, "B"),
(79, "C"),
(70, "C"),
(69, "D"),
(60, "D"),
(59, "F"),
(50, "F"),
(0, "F"),
])
def test_grade_boundaries(score, expected_grade):
assert calculate_grade(score) == expected_grade
Followed by the invalid input tests at the bottom:
def test_negative_score_raises_error():
with pytest.raises(ValueError):
calculate_grade(-1)
def test_score_over_100_raises_error():
with pytest.raises(ValueError):
calculate_grade(101)
def test_non_numeric_score_raises_error():
with pytest.raises(TypeError):
calculate_grade("ninety")
And here is what grade_calculator.py should look like:
def calculate_grade(score):
if not isinstance(score, (int, float)):
raise TypeError("Score must be a number")
if score < 0 or score > 100:
raise ValueError("Score must be between 0 and 100")
if score >= 90:
return "A"
elif score >= 80:
return "B"
elif score >= 70:
return "C"
elif score >= 60:
return "D"
else:
return "F"
Your test suite now covers both normal inputs and edge cases with just a few functions. Next up, you will add a second module and learn how to share test data across tests using fixtures.
Add a Second Module with Fixtures
Your test suite proves that calculate_grade handles every input correctly. Real software has multiple functions that work together, though.
In this step, you'll add a class statistics module and use pytest fixtures to provide reusable test data. This moves you from pure unit tests toward the next test level: integration testing.
In this step, get ready to:
- Write tests for a class statistics function using a pytest fixture.
- Implement the function and watch the tests go green.
- Deliberately introduce a bug and watch your tests catch it.
Write tests for class statistics using a fixture
A fixture is a function that provides reusable test data. Instead of repeating the same list of scores in every test function, you define it once as a fixture and pytest automatically passes it to any test that requests it.
- In test_grade_calculator.py, find the import line at the top of the file:
from grade_calculator import calculate_grade
- Update it to also import calculate_class_stats, so the line now looks like this:
from grade_calculator import calculate_grade, calculate_class_stats
- Add the following tests at the bottom of test_grade_calculator.py:
# Fixture provides reusable test data to any test that names it as a parameter
@pytest.fixture
def sample_scores():
return [85, 92, 78, 64, 95, 55, 73, 88, 41, 70]
def test_class_average(sample_scores):
stats = calculate_class_stats(sample_scores)
# pytest.approx handles floating-point comparison tolerance
assert stats["average"] == pytest.approx(74.1)
def test_class_highest(sample_scores):
stats = calculate_class_stats(sample_scores)
assert stats["highest"] == 95
def test_class_lowest(sample_scores):
stats = calculate_class_stats(sample_scores)
assert stats["lowest"] == 41
def test_class_pass_count(sample_scores):
stats = calculate_class_stats(sample_scores)
assert stats["pass_count"] == 8
def test_empty_class_raises_error():
with pytest.raises(ValueError):
calculate_class_stats([])
What does this code do?
- The @pytest.fixture decorator turns sample_scores into reusable test data. Any test function that names sample_scores as a parameter automatically receives the list.
- pytest.approx(74.1) handles floating-point comparison safely. Without it, tiny rounding differences could cause a false failure.
- test_empty_class_raises_error does not use the fixture because it tests the error case with an empty list instead.
- Save test_grade_calculator.py.
- Run the tests to see the Red phase by running:
pytest
You'll see failures because calculate_class_stats does not exist in grade_calculator.py yet. This is the Red phase. Your tests define the behaviour you want before you write the code.
Seeing a SyntaxError instead of an ImportError?
Double-check that your import line reads from grade_calculator import calculate_grade, calculate_class_stats with a comma between the two names.
Also confirm the @pytest.fixture decorator sits directly above def sample_scores(): with no blank line between them.
help me debug a SyntaxError in my test file
Implement the class statistics function
The tests have defined exactly what calculate_class_stats should do. Now write just enough code to make them pass.
- In grade_calculator.py, add the following function below the existing calculate_grade function:
def calculate_class_stats(scores):
# Guard against empty input
if not scores:
raise ValueError("Cannot calculate stats for an empty class")
# Compute summary statistics for the class
average = sum(scores) / len(scores)
highest = max(scores)
lowest = min(scores)
pass_count = sum(1 for s in scores if s >= 60)
return {
"average": average,
"highest": highest,
"lowest": lowest,
"pass_count": pass_count,
}
What does this code do?
- The empty-list check raises a ValueError immediately, matching what test_empty_class_raises_error expects.
- pass_count uses a generator expression to count scores at or above 60. A score of 60 earns a D, which is still a pass.
- The function returns a dictionary so tests can check individual keys independently.
- Save grade_calculator.py.
- Run pytest to confirm the Green phase by running:
pytest
All tests should now pass. The new statistics tests use the fixture data and verify each dictionary key.
Still seeing failures?
Check that the function name is exactly calculate_class_stats in both files. A typo in either the import or the function definition causes an ImportError.
Confirm the return dictionary uses the exact keys your tests check: average, highest, lowest, pass_count.
help me debug my calculate_class_stats function
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the full grade_calculator.py at this point:
def calculate_grade(score):
if not isinstance(score, (int, float)):
raise TypeError("Score must be a number")
if score < 0 or score > 100:
raise ValueError("Score must be between 0 and 100")
if score >= 90:
return "A"
elif score >= 80:
return "B"
elif score >= 70:
return "C"
elif score >= 60:
return "D"
else:
return "F"
def calculate_class_stats(scores):
if not scores:
raise ValueError("Cannot calculate stats for an empty class")
average = sum(scores) / len(scores)
highest = max(scores)
lowest = min(scores)
pass_count = sum(1 for s in scores if s >= 60)
return {
"average": average,
"highest": highest,
"lowest": lowest,
"pass_count": pass_count,
}
For test_grade_calculator.py, scroll through your file and confirm these sections exist in order:
- The import pytest line and the updated from grade_calculator import calculate_grade, calculate_class_stats line.
- test_score_90_returns_grade_A (your original test from Step 2).
- The parametrized test_grade_boundaries with 12 cases.
- The three invalid-input tests (test_negative_score_raises_error, test_score_over_100_raises_error, test_non_numeric_score_raises_error).
- The @pytest.fixture for sample_scores.
- Five new tests: test_class_average, test_class_highest, test_class_lowest, test_class_pass_count, test_empty_class_raises_error.
Run the full test suite and introduce a regression
Your test suite is now a safety net. To prove it actually catches bugs, you'll deliberately break something and watch the tests flag it immediately.
- Run the full suite in verbose mode by running:
pytest -v
You should see every test listed by name with a PASSED label next to each one.
Now simulate a real-world regression. Imagine a colleague refactors the pass threshold and accidentally changes the wrong number.
- In grade_calculator.py, find this line inside calculate_class_stats:
pass_count = sum(1 for s in scores if s >= 60)
- Change >= 60 to >= 70, so the line now looks like this:
pass_count = sum(1 for s in scores if s >= 70)
- Save grade_calculator.py.
- Run pytest again:
pytest
You'll see test_class_pass_count fail. The test expected 8 passing students but only got 6 because you raised the threshold. Your test suite caught the bug the instant it was introduced.
Why does this matter?
This is the testing principle "testing shows the presence of defects" in action. Without the test suite, this bug would silently ship. With it, the failure message tells you exactly which expectation broke and why.
- Fix the bug by changing >= 70 back to >= 60 in grade_calculator.py.
- Save the file.
Before you run pytest again, predict: how many tests do you expect to pass now that you've reverted the change?
- Confirm the fix by running:
pytest -v
Every test passes again. The verbose output lists each test name, including the parametrized cases with their parameters and the fixture-based statistics tests.
Still seeing a failure after reverting?
Make sure the line reads exactly s >= 60 (not >= 70 or > 60). Also confirm you saved the file after editing.
help me fix my reverted pass_count threshold
You now have two modules protected by a full test suite. You've seen tests catch a real bug the moment it was introduced. Next up, you'll take on the Secret Mission and write integration tests that verify both modules work together.
Secret mission
Add Integration Tests
Your unit tests verify each function in isolation. But what happens when calculate_grade and calculate_class_stats work together? In this mission, you'll build a generate_report function and write integration tests that prove your modules are wired together correctly.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The grade-calculator/ project folder on your Desktop (contains grade_calculator.py, test_grade_calculator.py, and test_integration.py).
- The pytest package installed in your Python environment.
Keep everything running
No action needed. Choose this if you're still actively building or want to continue experimenting with your tests.
- Your grade-calculator/ folder stays on your Desktop.
- The pytest package remains installed and available for other projects.
Pause - I'll come back to this later
Since this project is entirely local with no running processes, there is nothing to pause. Your files will stay on your Desktop until you decide to delete them.
- No action needed. Everything is already in a paused state.
Delete - I don't want to use this again
Remove all project files and uninstall pytest to start fresh.
- Uninstall pytest by running this command in your terminal:
pip uninstall pytest
- Enter y when prompted to confirm the uninstall.
- Delete the project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf grade-calculator
On Windows, run:
cd ~\Desktop
rmdir /s /q grade-calculator
- Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see grade-calculator listed.
Nice Work!
Nice Work!
You just built a fully tested Student Grade Calculator using Test-Driven Development. You wrote tests before code, watched them fail, wrote just enough to make them pass, and proved your test suite catches real bugs.
You've learned how to:
- Build a Python application using the Red-Green-Refactor cycle with pytest, writing tests before implementation code.
- Apply boundary value testing and @pytest.mark.parametrize to test dozens of inputs with a single function.
- Catch a real regression bug instantly by running your test suite, proving how tests protect code from breaking changes.
- Secret Mission: Wrote integration tests combining multiple modules to verify they work together correctly.
Ready to quiz yourself?