tl;dr
You can find the example solution using request.getfixture at the bottom of this post 🚀
Full code examples available in the e4ds-snippets GitHub repo
Pytest: Fixtures and Parameterize
DRY (do not repeat yourself) is a key concept in software development.
When writing tests it can be easy to write repetitive code.
For example, you might find yourself writing the same snippet of code multiple times to create and use the same object across different tests. Or, writing multiple tests for the same function in order to test different inputs.
Naturally, we want to minimise repetition as much as possible.
Pytest comes with two useful features to help avoid unnecessary repetition when writing tests:
- Fixtures – allow you to define a test input once and share it across many test functions.
- Parameterize – allows you to easily define and run multiple test cases for an individual test function.
These are two great features – so, it is logical to want to use them together.
But surprisingly, it is (still!
) not possible to directly use fixtures as arguments in pytest.mark.parameterize
.
In this post we will demonstrate how to use a workaround to allow you to use fixtures in parametrized tests.
Note: This article assumes basic familiarity with Pytest fixtures and parametrized tests. See the pytest documentation for more detailed explanations of how fixtures and parametrized tests work.
Example Function
Take this small snippet of code that tackles a common task in data science and engineering – parsing a string to extract information.
import re
def extract_ctry_date_and_ext_from_filename(filename: str) -> tuple[str, str, str]:
file_name_regex_pat = r"(\w+)_(\d{8})\.(.*)"
if matches := re.match(file_name_regex_pat, filename):
return matches.groups()
else:
raise ValueError(f"{filename} is not a valid filename")
In this example, the function takes a structured ‘file name’ as an input and uses a regular expression to extract the country, date and file extension.
For example:
>>> extract_ctry_date_and_ext_from_filename("us_20220917.csv")
('us', '20220917','csv')
Now let’s write a test for this function.
Single test
We can write a single test for the function as follows:
def test_extract_ctry_date_and_ext_from_fileanme():
example_filename = "us_20220917.csv"
expected_result = ('us','20220917','csv')
assert extract_ctry_date_and_ext_from_filename(example_filename) == expected_result
This is fine, however, it only tests a for a single test case. To be more thorough, we should test multiple different filenames and verify they all give the expected output.
This is where the pytest.mark.parametrize
function decorator comes in handy. It lets us create multiple test cases without having to write multiple test functions.
Parametrized test: pytest.mark.parametrized
Without using fixtures, we could directly pass two example filenames and their expected outputs to a parametrized test as follows:
import pytest
# parametrize without using fixtures
@pytest.mark.parametrize(
"filename,expected",
[
("us_20220917.csv", ("us", "20220917", "csv")),
("gb_20220917.csv", ("gb", "20220917", "csv")),
],
ids=["us", "gb"],
)
def test_extract_ctry_date_and_ext_from_fileanme(filename, expected):
assert extract_ctry_date_and_ext_from_filename(filename) == expected
Passing in the filenames manually like this works fine for a small single test. However, what if we want to re-use those filenames across many other tests.
To re-use the filename values across multiple tests would require the use of pytest fixtures .
Writing fixtures: pytest.fixture
For example we could write the following fixtures. This would allow us to define the filenames once and use across multiple tests.
@pytest.fixture(scope="module")
def test_us_csv_file_name():
return "us_20220917.csv"
@pytest.fixture(scope="module")
def test_gb_csv_file_name():
return "gb_20220917.csv"
Directly using fixtures as arguments in pytest parametrize ❌
So how do we use these fixtures in a parametrized test function?
Naively, one might try and pass the fixture directly as an argument to the parametrize inputs – after all, that is what you would do when using a fixture in a normal test function.
# naive approach: doesn't work :(
@pytest.mark.parametrize(
"filename,expected",
[
(test_us_csv_file_name, ("us", "20220917", "csv")),
(test_gb_csv_file_name, ("gb", "20220917", "csv")),
],
)
def test_extract_ctry_date_and_ext_from_fileanme(filename, expected):
assert extract_ctry_date_and_ext_from_filename(filename) == expected
But this will give a TypeError
:
Using request.getfixturevalue ✅
The solution is to use a work around. We should pass the fixture name as a string to our parametrized test inputs and then request the value of the fixture within the test.
To do this we can use an built-in pytest fixture called request .
Pytest comes with a number of useful fixtures which you can access by passing their name to your test function. Exactly how you would with your own custom fixtures.
The request
fixture has a method called getfixturevalue
. This method allows you to request the value of a fixture by its name. Just pass the name of the fixture as a string to the function to get the value.
Therefore, we can modify the example to use our custom fixture by:
- passing the custom fixture name as a string to
pytest.mark.parametrized
- adding
request
to the test function arguments - adding a line of code to request the value of the specified fixture when running the test
# parametrize with fixture
@pytest.mark.parametrize(
"filename,expected",
[
# 1. pass fixture name as a string
("test_us_csv_file_name", ("us", "20220917", "csv")),
("test_gb_csv_file_name", ("gb", "20220917", "csv")),
],
)
def test_extract_ctry_date_and_ext_from_fileanme(filename, expected, request):
# 2. add 'request' fixture to the test's arguments ^^
# 3. convert string to fixture value
filename = request.getfixturevalue(filename)
assert extract_ctry_date_and_ext_from_filename(filename) == expected
Update and clarification
This example explains the scenario where each parameterized test requires a different fixture.
If all parametrized tests share the same fixture, you do not need this ‘workaround’.
You can just add the fixture to the function *args like normal. You don’t need to include the fixture in the parametrized test definitions.
For example:
@pytest.fixture def fixture(): return "shared fixture" @pytest.mark.parametrize( "arg,expected", [ ("testval", "expected_output"), ("testval2", "expected_output2") ] ) def test(arg, expected, fixture): """Test func where fixture is the same for all parametrized tests""" assert my_test_func(fixture, arg) == expected
Conclusion
That’s it! You can now use your fixtures in parametrized tests using pytest.
It is pretty annoying that there is not a more intuitive way to pass fixtures to parametrized tests. But luckily there is a relatively simple workaround which does not need third party libraries.
Hopefully, native use of fixtures in parametrized tests will be added in a future pytest release, although it has been almost 10 years since the feature was first requested… so I’m not holding my breath😢
Full code example is available in the e4ds-snippets GitHub repo
Resources
Further Reading
- How to Always Enable Autoreloading of Modules in iPython
- Google Search Console API with Python
- What I learned optimising someone else’s code
- Deploying Dremio on Google Cloud
- Gitmoji: Add Emojis to Your Git Commit Messages!
- Five Tips to Elevate the Readability of your Python Code
- Do Programmers Need to be able to Type Fast?
- How to Manage Multiple Git Accounts on the Same Machine