Parametrizing Pytest Fixtures
Parametrizing pytest fixtures is a powerful technique for writing more concise and readable tests. I find parametrizing fixtures helpful in two cases.
- When testing multiple implementations of the same interface.
- When testing the same function against different inputs.
Testing interface implementations
Thanks to Python duck typing, you don’t have to explicitly define interfaces. Any object that has the required attributes and methods can be used as an interface implementation.
For the sake of exercise, let’s test python modules json and pickle. Each has “dumps” and “loads” methods, providing an implicit interface for serializing and restoring data. Below, we employ a parametrized fixture and write tests to ensure serializing and deserializing work as expected.
# file: test_serializers.py
import json
import pickle
import pytest
@pytest.fixture(params=[json, pickle])
def serializer(request):
return request.param
def test_serializer(serializer):
obj = {'a': 1, 'b': 2}
serialized = serializer.dumps(obj)
deserialized = serializer.loads(serialized)
assert obj == deserialized
Looking at the output, you can notice that the test has been executed twice: with “json” and “pickle” passed as a “serializer” parameter.
$ pytest -vv test_serializers.py
test_serializers.py::test_serializer[json] PASSED
test_serializers.py::test_serializer[pickle] PASSED
Parametrized fixtures are not that helpful for a single test — instead, you can parametrize the test function directly with pytest.mark.parametrize. However, they become more useful when you use the same fixture in multiple tests or perform extra steps to initialize fixture objects.
Testing different input
Here is a slightly different scenario. Imagine you test Pillow, a Python imaging library, to ensure it can resize all supported file formats. Unlike the above, the example below does not test various “actors.” Instead, the same actor is tested against different inputs.
from PIL import Image
@pytest.fixture(params=["./image.jpg", "./image.png"])
def image_path(request):
return request.param
def test_resize_image(image_path):
# resize the image
image = Image.open(image_path)
image = image.resize((100, 100))
# save it
dest_path = f"resized_{image_path}"
image.save(dest_path)
# check the dimensions of the resized file
assert Image.open(dest_path).size == (100, 100)
Combining parametrized fixtures
When using multiple parametrized fixtures, pytest will run the test for each parameter combination. The example below tests two serializers against two different inputs.
# file: test_serializers.py
import json
import pickle
import pytest
@pytest.fixture(params=[json, pickle])
def serializer(request):
return request.param
@pytest.fixture(params=[1, "foo"])
def obj(request):
return request.param
def test_serializer(serializer, obj):
serialized = serializer.dumps(obj)
deserialized = serializer.loads(serialized)
assert obj == deserialized
The output shows that the test has been run four times:
test_serializers.py::test_serializer[json-1] PASSED
test_serializers.py::test_serializer[json-foo] PASSED
test_serializers.py::test_serializer[pickle-1] PASSED
test_serializers.py::test_serializer[pickle-foo] PASSED
Skipping tests that shouldn’t work
If you are heavy on parameterized fixtures, sooner or later, you come across a case that doesn’t work for one combination of parameters. Pytest has a special skip() directive for this case.
Let’s extend the previous example with a datetime object, remembering that datetime is not JSON-serializable.
# file: test_serializers.py
import json
import pickle
import datetime
import pytest
@pytest.fixture(params=[json, pickle])
def serializer(request):
return request.param
@pytest.fixture(params=[1, "foo", datetime.datetime.now()])
def obj(request):
return request.param
def test_serializer(serializer, obj):
if isinstance(obj, datetime.datetime) and serializer is json:
pytest.skip("datetime objects are JSON-serialized")
serialized = serializer.dumps(obj)
deserialized = serializer.loads(serialized)
assert obj == deserialized
Here’s the output.
...
test_serializers.py::test_serializer[json-1] PASSED
test_serializers.py::test_serializer[json-foo] PASSED
test_serializers.py::test_serializer[json-obj2] SKIPPED (datetime objects are JSON-serialized)
... (more lines below) ...
As you can see, our specific case was skipped with a readable context message.