Skip to content

Commit 09e1b07

Browse files
travisjneumanclaude
andcommitted
feat: add Module 08 — Advanced Testing curriculum
Five projects covering parametrize, mocking, fixtures, property-based testing with Hypothesis, and FastAPI integration testing. Follows the alter/break/fix/explain pattern established by earlier modules. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d4b34ca commit 09e1b07

24 files changed

Lines changed: 2172 additions & 0 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Module 08 / Project 01 — Parametrize
2+
3+
[README](../../../../README.md) · [Module Index](../README.md)
4+
5+
## Focus
6+
7+
- `@pytest.mark.parametrize` to run one test function with many different inputs
8+
- Single-parameter and multi-parameter parametrize decorators
9+
- The `ids` parameter for readable test output
10+
- Testing utility functions with boundary values and edge cases
11+
12+
## Why this project exists
13+
14+
Writing a separate test function for every input you want to check is tedious and creates a wall of nearly identical code. `@pytest.mark.parametrize` solves this by letting you define one test function and feed it a table of inputs and expected outputs. Pytest runs it once per row, reporting each as a separate test case. This is how professional codebases test functions that must handle many different inputs.
15+
16+
## Run
17+
18+
```bash
19+
cd projects/modules/08-testing-advanced/01-parametrize
20+
pytest tests/test_utils.py -v
21+
```
22+
23+
## Expected output
24+
25+
```text
26+
tests/test_utils.py::test_validate_email[valid-user@example.com] PASSED
27+
tests/test_utils.py::test_validate_email[valid-name.tag@domain.org] PASSED
28+
tests/test_utils.py::test_validate_email[invalid-missing-at] PASSED
29+
tests/test_utils.py::test_validate_email[invalid-empty-string] PASSED
30+
...
31+
tests/test_utils.py::test_celsius_to_fahrenheit[freezing] PASSED
32+
tests/test_utils.py::test_celsius_to_fahrenheit[boiling] PASSED
33+
...
34+
tests/test_utils.py::test_is_palindrome[racecar-True] PASSED
35+
tests/test_utils.py::test_is_palindrome[hello-False] PASSED
36+
...
37+
tests/test_utils.py::test_clamp[below-minimum] PASSED
38+
tests/test_utils.py::test_clamp[above-maximum] PASSED
39+
...
40+
```
41+
42+
Each parametrized case appears as a separate test with a readable name. All should pass.
43+
44+
## Alter it
45+
46+
1. Add three more email test cases to `test_validate_email`: one with a `+` in the local part, one with multiple dots in the domain, and one with a missing domain.
47+
2. Add a parametrize case for `celsius_to_fahrenheit` using absolute zero (-273.15 C = -459.67 F).
48+
3. Add a new parametrized test for `clamp` where `min_val` equals `max_val`. What should happen when the range is a single point?
49+
50+
## Break it
51+
52+
1. Change one of the expected values in `test_celsius_to_fahrenheit` to be wrong (e.g., change 32.0 to 33.0). Run the tests and read the failure output carefully — pytest shows you exactly which parametrize case failed and what the actual vs expected values were.
53+
2. Remove the `ids` parameter from one of the parametrize decorators and run with `-v`. Notice how the test names become less readable.
54+
3. Add a duplicate test ID and see what pytest does.
55+
56+
## Fix it
57+
58+
1. Fix the broken expected value you changed above.
59+
2. Add the `ids` back and verify the verbose output is readable again.
60+
3. If any of your new test cases from "Alter it" fail, fix either the test or the function (decide which one is wrong first).
61+
62+
## Explain it
63+
64+
1. What is the difference between writing five separate test functions and one parametrized test with five cases?
65+
2. What does the `ids` parameter do and why does it matter?
66+
3. How does pytest report a failure in a parametrized test differently from a regular test?
67+
4. When would you use multi-parameter parametrize (multiple arguments) vs single-parameter?
68+
69+
## Mastery check
70+
71+
You can move on when you can:
72+
73+
- Write a parametrized test from memory, without looking at examples.
74+
- Explain what `ids` does and why you should use it.
75+
- Add new test cases to an existing parametrized test in under a minute.
76+
- Read pytest's parametrize failure output and understand which case failed and why.
77+
78+
## Next
79+
80+
[Project 02 — Mocking](../02-mocking/)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Notes — Parametrize
2+
3+
## What I learned
4+
5+
6+
## What confused me
7+
8+
9+
## What I want to explore next
10+
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""
2+
Project 01 — Parametrize
3+
4+
A collection of small utility functions designed to be tested with
5+
@pytest.mark.parametrize. Each function is simple enough to understand
6+
quickly, but has enough edge cases to make parametrized testing valuable.
7+
8+
These are the kind of utility functions you find in real codebases —
9+
small, pure, and used everywhere. Getting their edge cases right matters.
10+
"""
11+
12+
import re
13+
14+
15+
def validate_email(address):
16+
"""
17+
Check whether a string looks like a valid email address.
18+
19+
This is a simplified check — real email validation is surprisingly
20+
complex (RFC 5321 allows things you would never expect). We check for:
21+
- At least one @ symbol
22+
- Something before the @
23+
- Something after the @ that contains a dot
24+
25+
Returns True if the address looks valid, False otherwise.
26+
"""
27+
# An empty string or non-string input is never a valid email.
28+
if not isinstance(address, str) or not address:
29+
return False
30+
31+
# Use a simple regex pattern. This is not perfect, but it catches
32+
# the most common mistakes: missing @, missing domain, missing dot.
33+
# The pattern says: one or more characters, then @, then one or more
34+
# characters, then a dot, then one or more characters.
35+
pattern = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
36+
return bool(re.match(pattern, address))
37+
38+
39+
def celsius_to_fahrenheit(celsius):
40+
"""
41+
Convert a temperature from Celsius to Fahrenheit.
42+
43+
The formula is: F = C * 9/5 + 32
44+
45+
This is a textbook pure function — same input always gives same output,
46+
no side effects. Perfect for parametrized testing because you can list
47+
known conversion pairs and verify them all at once.
48+
"""
49+
# The formula comes from the relationship between the two scales.
50+
# Water freezes at 0°C / 32°F and boils at 100°C / 212°F.
51+
return celsius * 9 / 5 + 32
52+
53+
54+
def is_palindrome(text):
55+
"""
56+
Check whether a string reads the same forwards and backwards.
57+
58+
Ignores case and non-alphanumeric characters, so "A man, a plan,
59+
a canal: Panama" is considered a palindrome.
60+
61+
Returns True if the text is a palindrome, False otherwise.
62+
"""
63+
# Strip out everything that is not a letter or digit, then lowercase.
64+
# This lets us handle punctuation and mixed case gracefully.
65+
cleaned = re.sub(r"[^a-zA-Z0-9]", "", text).lower()
66+
67+
# An empty string after cleaning is technically a palindrome
68+
# (it reads the same in both directions: nothing).
69+
if not cleaned:
70+
return True
71+
72+
# Compare the string to its reverse. Python's slice [::-1] reverses
73+
# a string by stepping backwards through every character.
74+
return cleaned == cleaned[::-1]
75+
76+
77+
def clamp(value, min_val, max_val):
78+
"""
79+
Restrict a number to a given range.
80+
81+
If value is below min_val, return min_val.
82+
If value is above max_val, return max_val.
83+
Otherwise, return value unchanged.
84+
85+
This is a common utility in game development, UI code, and data
86+
processing — anywhere you need to keep a number within bounds.
87+
"""
88+
# Validate that the range makes sense. If someone passes min > max,
89+
# that is a programming error and we should not silently return garbage.
90+
if min_val > max_val:
91+
raise ValueError(
92+
f"min_val ({min_val}) must not be greater than max_val ({max_val})"
93+
)
94+
95+
# Python's built-in min() and max() make this a one-liner.
96+
# max(value, min_val) ensures we are at least min_val.
97+
# min(..., max_val) ensures we do not exceed max_val.
98+
return min(max(value, min_val), max_val)
99+
100+
101+
# ── Demo ────────────────────────────────────────────────────────────────
102+
# Run this file directly to see the functions in action.
103+
# The real tests are in tests/test_utils.py — run them with pytest.
104+
105+
if __name__ == "__main__":
106+
# Email validation examples
107+
print("=== Email Validation ===")
108+
test_emails = ["user@example.com", "bad-email", "", "a@b.c", "@missing.com"]
109+
for email in test_emails:
110+
print(f" {email!r:30s} -> {validate_email(email)}")
111+
112+
# Temperature conversion examples
113+
print("\n=== Celsius to Fahrenheit ===")
114+
test_temps = [0, 100, -40, 37]
115+
for c in test_temps:
116+
print(f" {c}°C -> {celsius_to_fahrenheit(c)}°F")
117+
118+
# Palindrome examples
119+
print("\n=== Palindrome Check ===")
120+
test_words = ["racecar", "hello", "A man a plan a canal Panama", ""]
121+
for word in test_words:
122+
print(f" {word!r:40s} -> {is_palindrome(word)}")
123+
124+
# Clamp examples
125+
print("\n=== Clamp ===")
126+
test_clamps = [(5, 0, 10), (-3, 0, 10), (15, 0, 10), (5, 5, 5)]
127+
for value, lo, hi in test_clamps:
128+
print(f" clamp({value}, {lo}, {hi}) -> {clamp(value, lo, hi)}")
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""
2+
Tests for Project 01 — Parametrize
3+
4+
This file demonstrates @pytest.mark.parametrize, which lets you run one
5+
test function with many different inputs. Instead of writing ten separate
6+
test functions that all look the same, you write one and give it a table
7+
of inputs and expected outputs.
8+
9+
Run with: pytest tests/test_utils.py -v
10+
The -v flag shows each parametrize case as a separate line in the output.
11+
"""
12+
13+
import pytest
14+
15+
# Import the functions we are testing. The ".." means "go up one directory"
16+
# but pytest handles this automatically when you run from the project root.
17+
from project import validate_email, celsius_to_fahrenheit, is_palindrome, clamp
18+
19+
20+
# ── validate_email ──────────────────────────────────────────────────────
21+
# WHY: Email validation has many edge cases. A parametrized test lets us
22+
# list them all in one place and add new cases without writing new functions.
23+
24+
@pytest.mark.parametrize(
25+
"email, expected",
26+
[
27+
# --- Valid emails ---
28+
("user@example.com", True), # Standard email
29+
("name.tag@domain.org", True), # Dots in local part
30+
("user+filter@gmail.com", True), # Plus addressing (common in Gmail)
31+
("x@y.io", True), # Minimal valid email
32+
("UPPER@CASE.COM", True), # Case should not matter for format
33+
34+
# --- Invalid emails ---
35+
("missing-at-sign", False), # No @ symbol at all
36+
("", False), # Empty string
37+
("@no-local-part.com", False), # Nothing before @
38+
("spaces in@email.com", False), # Spaces are not allowed
39+
("double@@at.com", False), # Two @ symbols
40+
],
41+
# ids makes the test output readable. Without it, pytest shows the raw
42+
# parameter tuple. With it, you get a human-friendly label.
43+
ids=[
44+
"valid-user@example.com",
45+
"valid-name.tag@domain.org",
46+
"valid-plus-addressing",
47+
"valid-minimal",
48+
"valid-uppercase",
49+
"invalid-missing-at",
50+
"invalid-empty-string",
51+
"invalid-no-local-part",
52+
"invalid-spaces",
53+
"invalid-double-at",
54+
],
55+
)
56+
def test_validate_email(email, expected):
57+
"""Each row in the parametrize table becomes a separate test case."""
58+
assert validate_email(email) == expected
59+
60+
61+
# WHY: We also want to make sure non-string inputs are handled gracefully.
62+
# This is a separate parametrize because the input type is different.
63+
64+
@pytest.mark.parametrize(
65+
"bad_input",
66+
[None, 42, [], {}],
67+
ids=["none", "integer", "list", "dict"],
68+
)
69+
def test_validate_email_rejects_non_strings(bad_input):
70+
"""Non-string inputs should always return False, never crash."""
71+
assert validate_email(bad_input) is False
72+
73+
74+
# ── celsius_to_fahrenheit ───────────────────────────────────────────────
75+
# WHY: Temperature conversion has well-known reference points. We test
76+
# the famous ones (freezing, boiling, body temp, the crossover point)
77+
# to make sure the formula is implemented correctly.
78+
79+
@pytest.mark.parametrize(
80+
"celsius, expected_fahrenheit",
81+
[
82+
(0, 32.0), # Freezing point of water
83+
(100, 212.0), # Boiling point of water
84+
(-40, -40.0), # The crossover point (same in both scales!)
85+
(37, 98.6), # Human body temperature
86+
(-273.15, -459.67), # Absolute zero
87+
],
88+
ids=["freezing", "boiling", "crossover", "body-temp", "absolute-zero"],
89+
)
90+
def test_celsius_to_fahrenheit(celsius, expected_fahrenheit):
91+
"""Verify known temperature conversion pairs."""
92+
# We use pytest.approx because floating-point math can introduce tiny
93+
# rounding errors. pytest.approx checks that the values are "close enough"
94+
# (within a very small tolerance, by default 1e-6).
95+
assert celsius_to_fahrenheit(celsius) == pytest.approx(expected_fahrenheit)
96+
97+
98+
# ── is_palindrome ───────────────────────────────────────────────────────
99+
# WHY: Palindrome checking involves string cleaning (removing punctuation,
100+
# ignoring case). We need to test both the core logic and the cleaning.
101+
102+
@pytest.mark.parametrize(
103+
"text, expected",
104+
[
105+
("racecar", True), # Classic palindrome
106+
("hello", False), # Clearly not a palindrome
107+
("A man a plan a canal Panama", True), # Sentence palindrome with spaces
108+
("Was it a car or a cat I saw?", True), # Punctuation and mixed case
109+
("", True), # Empty string is a palindrome
110+
("a", True), # Single character
111+
("ab", False), # Two different characters
112+
("Madam", True), # Mixed case palindrome
113+
],
114+
ids=[
115+
"racecar",
116+
"hello",
117+
"sentence-palindrome",
118+
"punctuation-mixed-case",
119+
"empty-string",
120+
"single-char",
121+
"two-different-chars",
122+
"mixed-case-madam",
123+
],
124+
)
125+
def test_is_palindrome(text, expected):
126+
"""Test palindrome detection with various inputs including edge cases."""
127+
assert is_palindrome(text) == expected
128+
129+
130+
# ── clamp ───────────────────────────────────────────────────────────────
131+
# WHY: Clamp has three distinct behaviors (below min, above max, in range)
132+
# plus edge cases at the boundaries. Parametrize lets us cover them all
133+
# in a compact table.
134+
135+
@pytest.mark.parametrize(
136+
"value, min_val, max_val, expected",
137+
[
138+
(5, 0, 10, 5), # Value already in range — returned unchanged
139+
(-3, 0, 10, 0), # Below minimum — clamped up to min
140+
(15, 0, 10, 10), # Above maximum — clamped down to max
141+
(0, 0, 10, 0), # Exactly at minimum — boundary case
142+
(10, 0, 10, 10), # Exactly at maximum — boundary case
143+
(5, 5, 5, 5), # Min equals max — only one valid value
144+
(-100, -50, 50, -50), # Negative range
145+
(3.5, 0, 10, 3.5), # Float value in range
146+
],
147+
ids=[
148+
"in-range",
149+
"below-minimum",
150+
"above-maximum",
151+
"at-minimum",
152+
"at-maximum",
153+
"single-point-range",
154+
"negative-range",
155+
"float-value",
156+
],
157+
)
158+
def test_clamp(value, min_val, max_val, expected):
159+
"""Test clamp with values inside, outside, and at the boundaries."""
160+
assert clamp(value, min_val, max_val) == expected
161+
162+
163+
# WHY: Clamp should raise an error when min > max. This is a separate test
164+
# because we are testing for an exception, not a return value.
165+
166+
def test_clamp_raises_on_invalid_range():
167+
"""Passing min > max is a programming error and should raise ValueError."""
168+
with pytest.raises(ValueError, match="must not be greater than"):
169+
clamp(5, 10, 0)

0 commit comments

Comments
 (0)