I love @pytest.mark.parametrize
1—so much so that I sometimes shoehorn my tests to fit
into it. But the default style of writing tests with parametrize
can quickly turn into an
unreadable mess as the test complexity grows. For example:
import pytest
from math import atan2
def polarify(x: float, y: float) -> tuple[float, float]:
r = (x**2 + y**2) ** 0.5
theta = atan2(y, x)
return r, theta
@pytest.mark.parametrize(
"x, y, expected",
[
(0, 0, (0, 0)),
(1, 0, (1, 0)),
(0, 1, (1, 1.5707963267948966)),
(1, 1, (2**0.5, 0.7853981633974483)),
(-1, -1, (2**0.5, -2.356194490192345)),
],
)
def test_polarify(x: float, y: float, expected: tuple[float, float]) -> None:
# pytest.approx helps us ignore floating point discrepancies
assert polarify(x, y) == pytest.approx(expected)
The polarify
function converts Cartesian coordinates to polar coordinates. We’re using
@pytest.mark.parametrize
in its standard form to test different conditions.
Here, the list of nested tuples with inputs and expected values becomes hard to read as the
test suite grows larger. When the function under test has a more complex signature, I find
myself needing to do more mental gymnastics to parse the positional input and expected
values inside parametrize
.
Also, how do you run a specific test case within the suite? For instance, what if you want
to run only the third case where x, y, expected = (0, 1, (1, 1.5707963267948966))
?
I used to set custom test IDs like below to be able to run individual test cases within
parametrize
:
# ... polarify implementation hasn't changed.
@pytest.mark.parametrize(
"x, y, expected",
[
(0, 0, (0, 0)),
(1, 0, (1, 0)),
(0, 1, (1, 1.5707963267948966)),
(1, 1, (2**0.5, 0.7853981633974483)),
(-1, -1, (2**0.5, -2.356194490192345)),
],
ids=[
"origin",
"positive_x_axis",
"positive_y_axis",
"first_quadrant",
"third_quadrant",
],
)
def test_polarify(x: float, y: float, expected: tuple[float, float]) -> None:
# pytest.approx helps us ignore floating point discrepancies
assert polarify(x, y) == pytest.approx(expected)
This works, but mentally associating the IDs with the examples is cumbersome, and it doesn’t make things any easier to read.
TIL, pytest.param
2 gives you a better syntax and more control to achieve the same.
Observe:
# ... polarify implementation hasn't changed.
@pytest.mark.parametrize(
"x, y, expected",
[
pytest.param(0, 0, (0, 0), id="origin"),
pytest.param(1, 0, (1, 0), id="positive_x_axis"),
pytest.param(0, 1, (1, 1.5707963267948966), id="positive_y_axis"),
pytest.param(1, 1, (2**0.5, 0.7853981633974483), id="first_quadrant"),
pytest.param(
-1, -1, (2**0.5, -2.356194490192345), id="third_quadrant"
),
],
)
def test_polarify(x: float, y: float, expected: tuple[float, float]) -> None:
# pytest.approx helps us ignore floating point discrepancies
assert polarify(x, y) == pytest.approx(expected)
We’re setting the unique IDs inside pytest.param
. Now, any test can be targeted with
pytest’s -k
flag like this:
pytest -k positive_x_axis
This will only run the second test case on the list.
Or,
pytest -k 'first or third'
This will run the last two tests.
But the test is still somewhat hard to read. I usually refactor mine to take a kwargs
argument so that I can neatly tuck all the input and expected values associated with a test
case in a single dictionary. Notice:
# ... polarify implementation hasn't changed.
@pytest.mark.parametrize(
"kwargs",
[
pytest.param({"x": 0, "y": 0, "expected": (0, 0)}, id="origin"),
pytest.param(
{"x": 1, "y": 0, "expected": (1, 0)}, id="positive_x_axis"
),
pytest.param(
{"x": 0, "y": 1, "expected": (1, 1.5707963267948966)},
id="positive_y_axis",
),
pytest.param(
{"x": 1, "y": 1, "expected": (2**0.5, 0.7853981633974483)},
id="first_quadrant",
),
pytest.param(
{"x": -1, "y": -1, "expected": (2**0.5, -2.356194490192345)},
id="third_quadrant",
),
],
)
def test_polarify(kwargs: dict[str, Any]) -> None:
# Extract expected from kwargs
expected = kwargs.pop("expected")
# Unpack the remaining kwargs to the polarify function
assert polarify(**kwargs) == pytest.approx(expected)
Everything associated with a single test case is passed to pytest.param
in a single
dictionary, eliminating the need to guess any positional arguments.
Using pytest.param
also allows you to set custom test execution conditionals, which I’ve
started to take advantage of recently:
# ... polarify implementation hasn't changed.
@pytest.mark.parametrize(
"kwargs",
[
pytest.param(
{"x": 0, "y": 1, "expected": (1, 1.5707963267948966)},
id="positive_y_axis",
marks=pytest.mark.xfail(
reason="Known issue with atan2 in this quadrant"
),
),
pytest.param(
{"x": 1, "y": 1, "expected": (2**0.5, 0.7853981633974483)},
id="first_quadrant",
),
pytest.param(
{
"x": 1e10,
"y": 1e10,
"expected": (2**0.5 * 1e10, 0.7853981633974483),
},
id="too_large",
marks=pytest.mark.skipif(
lambda kwargs: kwargs["x"] > 1e6 or kwargs["y"] > 1e6,
reason="Input values are too large",
),
),
],
)
def test_polarify(kwargs: dict[str, Any]) -> None:
# Extract expected from kwargs
expected = kwargs.pop("expected")
# Unpack the remaining kwargs to the polarify function
assert polarify(**kwargs) == pytest.approx(expected)
In the last block, pytest.param
bundles test data with execution conditions. We’re using
xfail
to mark a test as expected to fail, while skipif
skips tests based on conditions.
This keeps all the logic for handling test cases, including failures and skips, directly
alongside the test data.
Recent posts
- SSH saga
- Injecting Pytest fixtures without cluttering test signatures
- Explicit method overriding with @typing.override
- Quicker startup with module-level __getattr__
- Docker mount revisited
- Topological sort
- Writing a circuit breaker in Go
- Discovering direnv
- Notes on building event-driven systems
- Bash namerefs for dynamic variable referencing