About testing your code in Python (Part 3) — pytest Fixtures
In today's post, we discuss pytest fixtures, which are arguably one of the most useful things to use in a testing framework.
What are Fixtures?
In terms of testing frameworks, a fixture represents the “arrange” phase of a unit test. In other words, it helps you set up and tear down the conditions you need for one or more unit tests.
Since you want equal testing conditions for every test, you want to rebuild them for every test. Hence, the setup will be removed after each test. Because of those properties, fixtures can become arbitrarily complex.
Benefits of pytest Fixtures
Some of you might think that fixtures are a lot of additional work. Wouldn’t a few global variables at the beginning of each module do the trick?
Well, that’s a terrible idea for multiple reasons.
Reusing Your Setup Code
You can’t reuse and update your setup process easily. Since it’s at the beginning of each testing module, you’re bound to either recreate a separate file for each setup and import it (not a nice solution) or redefine it for each test module (even worse).
Fixtures, on the other hand, work across multiple test modules, and pytest imports them for you automatically if setup properly (later in this post). This gives you more flexibility and less maintenance work.
Having Multiple Setups
What happens if you need multiple setups for your tests? As described before, you could have a separate file for each and import them. But that would mean keeping track of variable names, which would end up in a disaster. You could start writing setup functions, though, but now we are moving into fixture territory already.
Fixtures go even one step further and allow you to decide which one to use on an individual test level. You can even use multiple fixtures in a test at once.
Controlling the Test Environment
You require as much control over your testing environment as possible. The reason is that you must guarantee that each test can be run independently of the other, i.e., is atomic.
Let’s assume you test a function that manipulates a given list. If you define a list in a global variable as part of the setup stage, all following testing functions will see that manipulated list. Now, another test function uses that list, but the assertion will fail because different list contents were expected.
You can adjust the assertion, so the test won’t fail, but then you create dependencies between those tests. The same would be true if you were able to manipulate the execution order.
Summary: pytest Fixture Benefits
The keen-eyed readers among you already extracted many benefits of fixtures by reading the last section. That said, it’s worth mentioning that not all benefits are equal among testing frameworks in general. The main benefits of pytest fixtures are:
- Reusability: Reuse them in between modules or in other fixtures.
- Auto-use: Define which fixtures are automatically used.
- Multi-use: Request fixtures more than once per test.
- Easily Maintainable: Adjust a fixture while its changes affect all tests that are using it.
Working with Fixtures
Now, that you know what fixtures bring to the table, let’s explore them hands-on.
Built-in Fixtures
pytest comes with some built-in fixtures. You can even extend those by installing additional packages. You can check which ones are available by entering pytest --fixtures -v
in the terminal.
Two especially useful fixtures are:
tmp_path
returns a unique temporaryPath
object as a subdirectory of the operating system's temporary folder. So, if you have tests that deal with files,tmp_path
is the way to go.monkeypatch
is a fixture that helps you with mocking/monkey patching. If you don’t know what this means, don’t worry; we’ll cover this topic later in this series.
Expanding our package
Before using a fixture, I would like to add two additional functions to math.py
in our package:
from pathlib import Path
def create_checkpoint_file(path:Path):
file_path = path/"checkpoint.txt"
file_path.touch()
return file_path
def create_checkpoint(file_path:Path, value:int):
file_path.write_text(str(value))
The first function creates an empty file in a given path, called checkpoint.txt
, and returns the Path
object to the file.
The second one writes a value into a file, ideally our checkpoint file.
Using Fixtures
Now, that we have a new function, we’ll test it by using a fixture. You simply add the fixture’s name as a parameter to the test function, like so:
from ..math import create_checkpoint_file, load_checkpoint_file
def test_create_checkpoint_file(tmp_path):
res_file_path = create_checkpoint_file(tmp_path)
assert res_file_path.is_file
Here, we create a checkpoint file in a temporary folder by using the tmp_path
fixture. Once we have a Path
object to the checkpoint file, we check if this file exists.
The neat part is that once we are done, we don’t have to worry about the checkpoint file cluttering our system. Since it’s in the operating system’s temporary folder, it will be automatically deleted, usually after a reboot.
Creating Fixtures
At this point, you know how to use fixtures, but what happens if you need a custom one?
create_checkpoint
is such a case: We would rather not add a setup routine to its test so we can focus on the test code. However, for the test to pass, we require a checkpoint file. Calling create_checkpoint_file
in the test function would harm our test independence. Custom fixtures help us achieve that.
But before we start coding, let's dissect the structure of fixtures first.
The Three Phases of a Fixture
Each fixture consists of three phases:
- Setup
- Yield
- Teardown
First, comes the setup phase, where you define all the preliminary steps the unit test needs to function, i.e., creating the checkpoint file.
The second phase, yield, enables you to pass its result directly to your test's code, i.e., yielding the Path
object.
Finally, comes the teardown phase. Here, the fixture cleans up everything it created during the setup, i.e., deletes the checkpoint file. That ensures that our tests are atomic.
In some cases, you might not need all the phases, but now you have seen them and know that they are there.
Our Fixture
By applying the structure from the last section, we can build the fixture from scratch:
@pytest.fixture
def checkpoint_file(tmp_path):
# Setup (Create file)
file_path = tmp_path/"checkpoint.txt"
file_path.touch()
# Yield (Yield the Path object)
yield file_path
# Teardown (Delete the file)
file_path.unlink()
The keen-eyed among you might have noticed that, despite the decorator, @pytest.fixture
our fixture is a Python function. Therefore, we only require adding the decorator to create a pytest fixture.
The even more keen-eyed among you might have noticed that we use another fixture within our fixture (Fixtureception!). That is helpful if you need to extend an existing fixture for a special use case.
Looking at the code, you might ask yourself why we yield
the Path
object and won’t return
it instead. Technically, you could do that, but then the fixture would skip the teardown phase, which we use. Therefore, it’s good practice to always use yield
in pytest fixtures.
Speaking of the teardown phase, in our case, it’s strictly speaking not necessary. Keep in mind that the file ends up in the OS temporary folder. However, there are still legitimate reasons to do it anyway:
- Cleanliness: You don’t let additional files live in your temp folder.
- Storage Constrained: If you need to save space on your disk, it’s good practice to get rid of unnecessary files.
Finally, our unit test should look like that:
def test_create_checkpoint(checkpoint_file):
create_checkpoint(checkpoint_file, 42)
assert checkpoint_file.read_text() == '42'
Verdict
In this post, we covered the basics about fixtures. But there is more to come; we barely scratched the surface.
The upcoming post in this series covers parametrizing your unit tests by using pytest’s fixture feature. After you learned how to do it, you’ll want to write all tests that way!