Partition a behavioral contract into representative and boundary cases, express them as readable pytest parameter nodes, and avoid hidden loops, copied oracles, mutation leaks, and combinatorial noise.
You will learn: Derive a compact case table from input partitions, create useful parametrized node IDs, and keep every expected value and failure independently understandable.
Practice in: A local pytest project for collection and failure IDs, with optional notebook work for designing case tables
The previous lesson produced several wind-risk tests with nearly identical shapes. Repetition is not automatically bad: every descriptive test made a boundary visible. Once the case design is sound, pytest parametrization can remove mechanical repetition while preserving a separate node and failure for each row.
This lesson begins before the decorator. You will decide which cases represent the contract, why each expected value is trustworthy, and how a failed case can identify itself without reading a list index.
Answer these questions as you build the suite:
Which inputs should behave alike, and where does behavior change?
Which just-below, exact, and just-above values expose a threshold?
How does parametrization differ from a loop inside one test?
What makes a parameter ID useful in a failure report?
When should cases remain separate rather than join one complicated table?
1. Partition the input space before writing rows
Meteor Watch now derives an alert level from wind and visibility:
def alert_level(wind_kph, visibility_km):"""Return green, amber, or red for valid weather measurements."""if wind_kph <0:raiseValueError("wind must be non-negative")if visibility_km <0:raiseValueError("visibility must be non-negative")if wind_kph >=70or visibility_km <1:return"red"if wind_kph >=40or visibility_km <5:return"amber"return"green"
The contract creates partitions:
wind: invalid below 0, green contribution below 40, amber contribution from 40 through just below 70, and red contribution from 70 upward;
visibility: invalid below 0, red contribution below 1, amber contribution from 1 through just below 5, and green contribution from 5 upward; and
combination: the most severe triggered condition wins.
An equivalence partition is a group of inputs that should follow the same rule. Testing one representative can support that class, while boundaries need special attention because comparison operators decide which neighboring class owns the exact value.
Start with one variable at a time while keeping the other safely ordinary:
Case ID
Wind
Visibility
Expected
Reason
calm-clear
12
10
green
ordinary safe measurements
wind-below-amber
39.9
10
green
just below wind 40
wind-at-amber
40
10
amber
exact wind 40
wind-below-red
69.9
10
amber
just below wind 70
wind-at-red
70
10
red
exact wind 70
visibility-below-green
12
4.9
amber
just below visibility 5
visibility-at-green
12
5
green
exact visibility 5
visibility-below-amber
12
0.9
red
just below visibility 1
visibility-at-amber
12
1
amber
exact visibility 1
The IDs use the resulting interval, not an ambiguous phrase such as visibility-boundary-2. Read visibility-at-green as “at the lower boundary of the green visibility interval.” If that wording feels unclear to your team, prefer visibility-five-is-green. A useful ID needs no private legend.
Boundary values belong to one side of a comparison. The three observations around 40 reveal whether the implementation uses <, <=, or an incorrect threshold.
flowchart LR
A["39.9: green"] --> B["40: amber"] --> C["40.1: amber"]
D["wind below 40"] --> A
E["wind at least 40"] --> B
E --> C
2. Turn the table into separate pytest nodes
@pytest.mark.parametrize supplies each row to the same test function:
Without explicit IDs, pytest creates IDs from values. They may be adequate for simple rows, but test_alert_level_boundaries[12-10-green] does not say why those numbers were chosen.
If both functions accidentally use 71 as the red threshold, the tests agree with each other and disagree with the contract. Put small expected values directly in the rows. An independent reference model can be valuable for a complex algorithm, but then the model should be obviously simpler and tested or reviewed separately.
Explicit rows also make changed requirements reviewable:
If red risk later begins at 65, a reviewer can see which contract rows changed. Do not change production and every expected row before observing red. Update the test contract first, confirm the relevant case fails for the intended old behavior, then implement the new rule.
4. Prefer parametrization to a loop that hides the case
This test stops at the first failing iteration but reports only one node:
Pytest can show the local values, but you cannot select only the 70 row by node ID, and a failure at 40 prevents the later case from running. A loop is appropriate when iteration itself is the behavior under test. Use parametrization when the loop is merely a test-data delivery mechanism.
Do not compensate with a vague message:
def test_wind_cases_with_a_message():for wind_kph, expected in [(40, "amber"), (70, "red")]: observed = alert_level(wind_kph, 10)assert observed == expected, f"failed for {wind_kph}"
The message helps, but separate parameter nodes still provide selection, collection counts, and independent outcomes.
5. Separate result and exception tables when they tell different stories
Valid cases return a label. Invalid measurements raise a specific exception. Trying to represent both with expected values and branches inside one test makes the assertion path harder to read:
@pytest.mark.parametrize( ("wind_kph", "visibility_km", "expected_message"), [ (-0.1, 10, "wind must be non-negative"), (10, -0.1, "visibility must be non-negative"), ],)def test_invalid_measurements(wind_kph, visibility_km, expected_message):with pytest.raises(ValueError, match=expected_message): alert_level(wind_kph, visibility_km)
The result table can remain a direct equality assertion. The invalid table can remain a direct exception assertion. The separation is useful because each group has one readable shape.
If exception types vary meaningfully, add expected_exception as data:
Only use this form when the production contract really distinguishes those types. Do not create a complicated universal table merely to avoid two clear test functions.
6. Choose interacting cases without building the entire Cartesian product
Two inputs create combinations. Nine wind cases multiplied by nine visibility cases would produce 81 nodes. More cases are not automatically better if most repeat the same rule and hide the important interactions.
Use three layers:
isolate each threshold while the other input is safely ordinary;
add combinations where rules compete; and
add a named regression when a real interaction failed.
Each row adds a reason. red-outranks-amber checks rule priority. A dozen other red-plus-amber number pairs would not add the same amount of confidence.
Nested parametrization intentionally creates a Cartesian product:
This produces nine nodes and protects a broad vocabulary invariant. It does not replace exact expected results for priority and boundary rules. Use a Cartesian product when each combination is meaningful or the set is intentionally small.
7. Prevent parameter mutation from leaking between cases
Pytest passes parameter values as supplied; it does not copy mutable lists or dictionaries for each use. This production function mutates its argument:
def add_risk(record): record["risk"] = alert_level( record["wind_kph"], record["visibility_km"], )return record
Reusing the same dictionary object in more than one row can let the first case change the arrangement for the next. Prefer independent literals:
If the same base object must generate variations, construct a fresh copy in the test or use a fixture factory in the next lesson. Do not use deepcopy by habit; first decide which nested objects the function is allowed to mutate.
8. Keep a discovered bug visible as a named regression
Suppose alert_level(45, 0.9) once returned "amber" because wind was checked before severe visibility. It could be another table row, but a dedicated test name preserves why the case matters:
The separate name helps maintainers connect a future failure to the past bug. Use that visibility sparingly. If every ordinary row becomes a standalone “regression,” the suite loses its case-table structure.
9. Test the changed requirement before changing production
Imagine the wind red threshold changes from 70 to 65. Make this controlled change:
replace the relevant table IDs and values so 64.9 is amber and 65 is red;
run the selected wind-sixty-five-is-red case against the old production code;
confirm it fails with amber versus red;
change the production threshold once;
run all wind rows and competing-rule rows; and
inspect whether any former 69.9 expected value must change because the contract changed.
This order separates a requirement migration from an assertion edited only to make a current implementation pass.
negative wind or visibility raises ValueError naming the invalid field;
wind at least 70 or visibility below 1 produces "red";
otherwise, wind at least 40 or visibility below 5 produces "amber";
all other valid measurements produce "green"; and
red conditions outrank amber conditions.
Your final suite should contain:
a wind-boundary table with meaningful IDs;
a visibility-boundary table with meaningful IDs;
a compact interaction table showing red outranks amber;
a separate invalid-input table using pytest.raises;
one named regression for wind=45, visibility=0.9;
no expected-value helper that repeats the production branches; and
no loop used merely to deliver cases.
Temporarily introduce two defects—wind_kph > 70 and checking amber before red. Use the failed parameter IDs to identify each rule. Repair one defect at a time, run the focused node, then the whole file.
Hint 1: derive rows from each comparison
For wind, hold visibility at 10 and cover 39.9, 40, 69.9, and 70. For visibility, hold wind at 10 and cover 0.9, 1, 4.9, and 5.
Hint 2: make priority a competing pair
Use wind 45 to trigger amber and visibility 0.9 to trigger red. The combined expected result is red because the more severe condition wins.
Hint 3: keep invalid behavior in its own table
Use rows containing wind, visibility, and a stable message fragment. One test body can put only the alert_level(...) call inside pytest.raises.
Show a compact complete suite