Testing Functions and Return Values
We systematically test return values across the project, including edge cases: empty inputs, boundary values, and the exact examples used in each session's own concept explanation.
Normal cases, edge cases, and the boundary in between.
1. Learning Objective
By the end of this session you will be able to:
- Write multiple test functions covering a normal case, an edge case, and a boundary case for one function
- Use pytest parametrization to run the same test logic across several inputs
- Test a list comprehension's output by checking exact contents, not just length
- Test the from_dict classmethod from Session 18 with both valid and malformed input
- Identify what "edge case" means concretely, using examples from this project
2. Pre-Coding Quiz
Answer these before reading the concept explanation or writing any code. It is fine to get these wrong โ that is the point. You need 4/5 to proceed to the lab.
For a function find_by_region(countries, region), what is an example of an "edge case" worth testing, beyond the normal "region exists with matches" case?
What does @pytest.mark.parametrize let you do?
@pytest.mark.parametrize decorates a test function with a list of input/expected pairs, and pytest runs the same test body once per pair โ avoiding duplicated test functions that differ only in their specific values.You want to test that [c.name for c in explorer.find_by_region("Africa")] returns exactly ["Kenya", "Ghana"]. Why is asserting the exact list better than just asserting len(result) == 2?
Testing Country.from_dict() with a dict missing the "population" key should do what?
Why is it valuable for a test to use the EXACT same example from a session's own concept explanation, rather than an unrelated new example?
3. The Concept โ Testing Return Values Systematically
One test body, run automatically once per parametrized case โ no copy-pasted near-duplicate test functions.
Three kinds of cases: normal, edge, and boundary
A thorough test suite for a function covers its normal, expected use; edge cases (empty inputs, zero matches); and boundary cases (values right at a validation limit, like exactly 0).
# country.py additions
def find_by_region(countries, region):
return [c for c in countries if c.region == region]
# tests/test_find_by_region.py
from country import Country, find_by_region
def test_find_by_region_normal_case():
countries = [
Country(name="Kenya", region="Africa", population=54000000),
Country(name="Peru", region="Americas", population=33000000),
]
result = find_by_region(countries, "Africa")
assert [c.name for c in result] == ["Kenya"] # exact contents, not just length
def test_find_by_region_no_matches_returns_empty_list():
countries = [Country(name="Kenya", region="Africa", population=54000000)]
result = find_by_region(countries, "Antarctica")
assert result == [] # Session 17's guarantee, verified
def test_find_by_region_empty_source_list():
result = find_by_region([], "Africa")
assert result == []
Parametrizing repetitive test logic
When the same test logic needs to run against several input/output pairs, @pytest.mark.parametrize avoids copy-pasting near-identical test functions.
import pytest
from country import Country
@pytest.mark.parametrize("value,expected_valid", [
(54000000, True),
(0, True), # boundary โ exactly zero should be valid
(-1, False), # boundary โ just below zero should be invalid
(-999999, False),
])
def test_set_population_validity(value, expected_valid):
k = Country(name="Kenya", region="Africa", population=1)
if expected_valid:
k.set_population(value)
assert k.population == value
else:
with pytest.raises(ValueError):
k.set_population(value)
Testing from_dict with valid and malformed input
Session 18's classmethod is a natural place for edge-case testing: what happens with correct data, and what happens with a missing required field.
import pytest
from country import Country
def test_from_dict_builds_correct_instance():
data = {"name": "Kenya", "region": "Africa", "population": 54000000}
k = Country.from_dict(data)
assert k.name == "Kenya"
assert k.population == 54000000
def test_from_dict_missing_field_raises():
data = {"name": "Ghost Nation"} # missing region and population
with pytest.raises(TypeError):
Country.from_dict(data)
4. Lab
What you will build
A file called tests/test_return_values.py, building on Session 32's country.py.
Step-by-step instructions
Add find_by_region and from_dict to country.py if not already present
# country.py (additions)
def find_by_region(countries, region):
return [c for c in countries if c.region == region]
class Country:
# ... existing __init__, summary, set_population, grow_population ...
@classmethod
def from_dict(cls, data):
return cls(**data)
Write normal-case and exact-content tests for find_by_region
# tests/test_return_values.py
import pytest
from country import Country, find_by_region
def test_find_by_region_returns_exact_matches():
countries = [
Country(name="Kenya", region="Africa", population=54000000),
Country(name="Ghana", region="Africa", population=31000000),
Country(name="Peru", region="Americas", population=33000000),
]
result = find_by_region(countries, "Africa")
assert [c.name for c in result] == ["Kenya", "Ghana"]
Add edge case tests: no matches, empty source
def test_find_by_region_no_matches():
countries = [Country(name="Kenya", region="Africa", population=54000000)]
assert find_by_region(countries, "Antarctica") == []
def test_find_by_region_empty_source():
assert find_by_region([], "Africa") == []
Add from_dict tests, valid and malformed
def test_from_dict_valid():
k = Country.from_dict({"name": "Kenya", "region": "Africa", "population": 54000000})
assert k.name == "Kenya"
assert k.population == 54000000
def test_from_dict_missing_field_raises():
with pytest.raises(TypeError):
Country.from_dict({"name": "Ghost Nation"})
Add a parametrized boundary test for set_population
@pytest.mark.parametrize("value,should_succeed", [
(54000000, True),
(0, True),
(-1, False),
])
def test_set_population_boundaries(value, should_succeed):
k = Country(name="Kenya", region="Africa", population=1)
if should_succeed:
k.set_population(value)
assert k.population == value
else:
with pytest.raises(ValueError):
k.set_population(value)
# Run: pytest -v (the -v flag shows each parametrized case individually)
5. Expected Files Changed
| File | Action | Why |
|---|---|---|
country.py |
Modified | Adds find_by_region and from_dict if not already present. |
tests/test_return_values.py |
Created | Systematic normal/edge/boundary test coverage, including parametrized cases. |
docs/sessions/session-33/index.html |
Created | This session document. |
6. Commit Checkpoint
Once the lab is complete and you can explain every line, make this exact commit:
git add country.py tests/test_return_values.py docs/sessions/session-33/index.html
git commit -m "session-33: systematically test return values, including edge and boundary cases"
7. Code Review Checklist
Go through your code line by line and check each item:
8. Post-Coding Quiz
Same 5 questions. Take it again now that you have written and run the code. You need 4/5 to mark this session complete.
For a function find_by_region(countries, region), what is an example of an "edge case" worth testing, beyond the normal "region exists with matches" case?
What does @pytest.mark.parametrize let you do?
@pytest.mark.parametrize decorates a test function with a list of input/expected pairs, and pytest runs the same test body once per pair โ avoiding duplicated test functions that differ only in their specific values.You want to test that [c.name for c in explorer.find_by_region("Africa")] returns exactly ["Kenya", "Ghana"]. Why is asserting the exact list better than just asserting len(result) == 2?
Testing Country.from_dict() with a dict missing the "population" key should do what?
Why is it valuable for a test to use the EXACT same example from a session's own concept explanation, rather than an unrelated new example?
9. Reflection Questions
Think through these after the post-quiz. No right answer โ they are for discussion.
- Why does testing the boundary value 0 for set_population matter specifically, given the validation rule is value < 0?
- Could a bug exist where find_by_region returns the correct COUNT of countries but the WRONG countries? Design a scenario where only an exact-content test would catch it.
- How did parametrizing the set_population boundary test compare, in terms of clarity, to writing three separate test functions?
- Which of today's tests would have caught a real bug from an earlier session, if that bug had existed?
10. What Breaks If This Knowledge Is Missing?
- False confidence from weak assertions: A test suite full of length-only or existence-only checks gives a false sense of safety โ it can pass while the actual returned data is subtly wrong, exactly the gap exact-content assertions close.
- Testing state changes (Session 34): This session focused on return VALUES. The next session tests state CHANGES on an object over time โ a related but distinct testing skill, since not every method returns something meaningful; some just mutate.
- Regression safety for the whole project: Every function and method tested in this session is now protected against silent regressions in every remaining session of the curriculum โ this is the payoff of investing in test coverage now.
11. What We Learned
Python concept mastered: Systematic testing of return values โ normal, edge, and boundary cases, exact-content assertions, and pytest parametrization.
Unlocks: The project's core data-returning functions are now protected by a thorough, systematic test suite, not just a handful of happy-path checks.
Next session: Session 34 โ Testing Classes and State Changes. We test the other half of the picture: methods that change an object's state over time, rather than returning a value.