In this article, you’ll deep dive into a powerful testing feature in Python called Pytest Fixtures. Feel free to dive into our background articles on Pytest in case you need a quick refresher (with video)!
You can watch this tutorial in video format or just read the article with code. Here’s the video first:
What are Pytest Fixtures?
Pytest fixtures are functions that you can use to initialise your test environment. They can provide consistent test data or set up the initial state of the environment.Β
Software testing often requires specific dependencies, such as input data or external resources. It can be tedious and inefficient if each test case needs to create dependencies on its own. pytest fixtures can provide a solution for the problem.
In pytest, you can create a function that provides the dependency and mark it as a fixture. Then, test cases can use it when you specify the function name as an argument. Fixtures are modular by design and are easy to share across test cases in different files. In addition, it is easy to add steps to clean up the resources. Let’s look at the details.
How to Use a Pytest Fixture?
Test Case Without a Fixture
Letβs use a simple example of a Python class Customer as shown below.
customer.py
class Customer: def __init__(self, cust_id, level=0): self._cust_id = cust_id self._level = level def __str__(self): return f'< Customer cust_id = {self._cust_id}, level = {self._level} >' @property def cust_id(self): return self._cust_id @property def level(self): return self._level def level_up(self): self._level += 1 def level_down(self): self._level -= 1 if self._level >= 1 else self._level
This class has two properties, cust_id
and level
, which can be accessed using the getter methods cust_id()
and level()
, respectively. It also has two methods, level_up()
and level_down()
, to increase and decrease the level value by 1, respectively, but when decreasing the level, the level value should not go below 0. The initializer takes parameters for cust_id
and level
, and the default value of level
is set to 0 if not specified.
Letβs first look at an example without fixtures. We can create simple test cases to test this class, as shown below.
test_customer1.py
from customer import Customer def test_customer_has_default_properties(): customer = Customer(100) assert customer.cust_id == 100 assert customer.level == 0 def test_customer_has_initial_level(): customer = Customer(100, 1) assert customer.cust_id == 100 assert customer.level == 1 def test_customer_level_up_increases_level(): customer = Customer(100) customer.level_up() assert customer.level == 1 def test_customer_level_down_decreases_level(): customer = Customer(100) customer.level_up() customer.level_down() assert customer.level == 0 def test_customer_level_not_goes_below_0(): customer = Customer(100) customer.level_down() assert customer.level == 0
When you run the pytest
command, the tests should pass.
$ pytest test_customer1.py ================= test session starts ================== platform darwin -- Python 3.9.1, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 rootdir: /Users/mikio/pytest3 collected 5 items test_customer1.py ..... [100%] ================== 5 passed in 0.00s ===================
But looking at the test cases, you might notice that each test case creates a new Customer
object. In this example, we need only one line to create an object, but it can be more complex, and it would be inefficient to repeat the same steps in each test case. Let’s make it more efficient by using a fixture.
How to Create a Fixture
You can create a fixture by defining a function with the @pytest.fixture
decorator in the following three steps:
- Import Pytest into the test file.
import pytest
- Create a function with the
@pytest.fixture
decorator.
@pytest.fixture def customer(): _customer = Customer(100) return _customer
- Specify the function name as an argument in the test functions.
def test_customer_has_default_properties(customer): assert customer.cust_id == 100 assert customer.level == 0
Now the entire test file looks like this:
test_customer2.py
from customer import Customer import pytest @pytest.fixture def customer(): _customer = Customer(100) return _customer def test_customer_has_default_properties(customer): assert customer.cust_id == 100 assert customer.level == 0 def test_customer_has_initial_level(): customer = Customer(100, 1) assert customer.cust_id == 100 assert customer.level == 1 def test_customer_level_up_increases_level(customer): customer.level_up() assert customer.level == 1 def test_customer_level_down_decreases_level(customer): customer.level_up() customer.level_down() assert customer.level == 0 def test_customer_level_not_goes_below_0(customer): customer.level_down() assert customer.level == 0
The second test function (test_customer_has_initial_level
) is not requesting (i.e. using) the fixture because the object is initialized differently. Still, we have managed to eliminate the object initialization from the other test functions.
The tests should still pass.
$ pytest test_customer2.py ================= test session starts ================== platform darwin -- Python 3.9.1, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 rootdir: /Users/mikio/pytest3 collected 5 items test_customer2.py ..... [100%] ================== 5 passed in 0.00s ===================
How to Share Fixtures?Β
In the previous section, we eliminated some repetitions in the test functions by using a fixture. But if the fixture stays in the same test file, you might not find it very useful. One of the advantages of using fixtures is that they can be shared across multiple test files in the same directory and subdirectories. All you need to do is to define them in a file called conftest.py
.
How to Use conftest.py
Let’s create the file conftest.py
in the current directory and move the fixture from the test file. Be sure to import the class and Pytest as well.
conftest.py
from customer import Customer import pytest @pytest.fixture def customer(): _customer = Customer(100) return _customer
Now the test file looks like this:
test_customer3.py
from customer import Customer def test_customer_has_default_properties(customer): assert customer.cust_id == 100 assert customer.level == 0 def test_customer_has_initial_level(): customer = Customer(100, 1) assert customer.cust_id == 100 assert customer.level == 1 def test_customer_level_up_increases_level(customer): customer.level_up() assert customer.level == 1 def test_customer_level_down_decreases_level(customer): customer.level_up() customer.level_down() assert customer.level == 0 def test_customer_level_not_goes_below_0(customer): customer.level_down() assert customer.level == 0
When the fixture is not defined in the same file, Pytest automatically looks for conftest.py
and finds it in the file, so the tests should still pass. If you had other test files in the same directory, the fixture would automatically become available in the tests (but we will only use a single test file in this article).
$ pytest test_customer3.py ================= test session starts ================== platform darwin -- Python 3.9.1, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 rootdir: /Users/mikio/pytest3 collected 5 items test_customer3.py ..... [100%] ================== 5 passed in 0.00s ===================
How to Set a Fixture Scope
Each fixture has a scope. The default scope is function, meaning that fixtures are initialized when requested in a test function and destroyed when the test function finishes. It is the same behavior as our first test file, test_customer1.py
, where each function creates a Customer
object.
Let’s verify the scope by looking at the object ID. Add a print statement to each test function, as shown below.
test_customer4.py
from customer import Customer def test_customer_has_default_properties(customer): print(f'{id(customer)=}') assert customer.cust_id == 100 assert customer.level == 0 def test_customer_has_initial_level(): customer = Customer(100, 1) print(f'{id(customer)=}') assert customer.cust_id == 100 assert customer.level == 1 def test_customer_level_up_increases_level(customer): print(f'{id(customer)=}') customer.level_up() assert customer.level == 1 def test_customer_level_down_decreases_level(customer): print(f'{id(customer)=}') customer.level_up() customer.level_down() assert customer.level == 0 def test_customer_level_not_goes_below_0(customer): print(f'{id(customer)=}') customer.level_down() assert customer.level == 0
Then run Pytest with the -rP
option, which shows the output of print statements in the summary info section.Β
$ pytest test_customer4.py -rP ================= test session starts ================== platform darwin -- Python 3.9.1, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 rootdir: /Users/mikio/pytest3 collected 5 items test_customer4.py ..... [100%] ======================== PASSES ======================== _________ test_customer_has_default_properties _________ ----------------- Captured stdout call ----------------- id(customer)=4384350896 ___________ test_customer_has_initial_level ____________ ----------------- Captured stdout call ----------------- id(customer)=4384440480 ________ test_customer_level_up_increases_level ________ ----------------- Captured stdout call ----------------- id(customer)=4384440528 _______ test_customer_level_down_decreases_level _______ ----------------- Captured stdout call ----------------- id(customer)=4384440624 _________ test_customer_level_not_goes_below_0 _________ ----------------- Captured stdout call ----------------- id(customer)=4384440576 ================== 5 passed in 0.00s ===================
As you can see, the object ID is different in each test function, which means each object is different.
You can use other scopes, such as session
. The session scope means that fixtures are initialized when they are first requested in the test session. Then, Pytest uses the same object during the test session and destroys it when the test session ends.
Let’s try changing the scope of our fixture to session. You can change the scope by adding the parameter scope to the @pytest.fixture
decorator.
conftest.py
from customer import Customer import pytest @pytest.fixture(scope='session') def customer(): _customer = Customer(100) return _customer
Now, when you run the tests, the 4th test (test_customer_level_down_decreases_level
) fails because the customer object is now shared across the test functions, as you can see from the object ID value.Β
$ pytest test_customer4.py -rP ================= test session starts ================== platform darwin -- Python 3.9.1, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 rootdir: /Users/mikio/pytest3 collected 5 items test_customer4.py ...F. [100%] ======================= FAILURES ======================= _______ test_customer_level_down_decreases_level _______ customer = <customer.Customer object at 0x10143a250> def test_customer_level_down_decreases_level(customer): print(f'{id(customer)=}') customer.level_up() customer.level_down() > assert customer.level == 0 E assert 1 == 0 E + where 1 = <customer.Customer object at 0x10143a250>.level test_customer4.py:23: AssertionError ----------------- Captured stdout call ----------------- id(customer)=4316176976 ======================== PASSES ======================== _________ test_customer_has_default_properties _________ ----------------- Captured stdout call ----------------- id(customer)=4316176976 ___________ test_customer_has_initial_level ____________ ----------------- Captured stdout call ----------------- id(customer)=4316365056 ________ test_customer_level_up_increases_level ________ ----------------- Captured stdout call ----------------- id(customer)=4316176976 _________ test_customer_level_not_goes_below_0 _________ ----------------- Captured stdout call ----------------- id(customer)=4316176976 ============= 1 failed, 4 passed in 0.02s ==============
When the third function (test_customer_level_up_increases
_level) runs, the level value of the customer object is increased. So, the customer object in the 4th test function does not have the default level value. We should probably update the tests as shown below.
test_customer5.py
from customer import Customer def test_customer_has_default_properties(customer): print(f'{id(customer)=}') assert customer.cust_id == 100 assert customer.level == 0 def test_customer_has_initial_level(): customer = Customer(100, 1) print(f'{id(customer)=}') assert customer.cust_id == 100 assert customer.level == 1 def test_customer_level_up_increases_level(customer): print(f'{id(customer)=}') original_level = customer.level customer.level_up() assert customer.level == original_level + 1 def test_customer_level_down_decreases_level(customer): print(f'{id(customer)=}') original_level = customer.level customer.level_up() customer.level_down() assert customer.level == original_level def test_customer_level_not_goes_below_0(customer): print(f'{id(customer)=}') original_level = customer.level customer.level_down() assert customer.level == (original_level - 1 if original_level >= 1 else 0)
Now the tests should pass.
$ pytest test_customer5.py -rP ================= test session starts ================== platform darwin -- Python 3.9.1, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 rootdir: /Users/mikio/pytest3 collected 5 items test_customer5.py ..... [100%] ======================== PASSES ======================== _________ test_customer_has_default_properties _________ ----------------- Captured stdout call ----------------- id(customer)=4395372800 ___________ test_customer_has_initial_level ____________ ----------------- Captured stdout call ----------------- id(customer)=4395373088 ________ test_customer_level_up_increases_level ________ ----------------- Captured stdout call ----------------- id(customer)=4395372800 _______ test_customer_level_down_decreases_level _______ ----------------- Captured stdout call ----------------- id(customer)=4395372800 _________ test_customer_level_not_goes_below_0 _________ ----------------- Captured stdout call ----------------- id(customer)=4395372800 ================== 5 passed in 0.00s ===================
How to Add Arguments to a Fixture?
We’ve been using a fixture, but it is a little bit limited because it only has a fixed cust_id and the default level value. It would be more useful if we could create an object using different parameter values.
Instead of creating an object with fixed parameter values, you can create a fixture that returns a function to create an object so that you can specify the parameter values as arguments in the function. This style is sometimes called Factory pattern in object-oriented programming, so let’s create a new fixture in conftest.py
and call it customer_factory
as shown below.
conftest.py
from customer import Customer import pytest @pytest.fixture(scope='session') def customer(): _customer = Customer(100) return _customer @pytest.fixture(scope='session') def customer_factory(): def _customer(cust_id, level=0): _cust = Customer(cust_id, level) return _cust return _customer
In the function customer_factory
, another inner function _custoemr()
is defined. The inner function uses two argument values (cust_id
and level
) to create a Customer
object and returns it. When this fixture is requested, the test function does not receive a Customer
object but receives this inner function instead. So, it can create a Customer
object with any parameter values.
Let’s use this fixture in the test file. In the second function (test_customer_has_initial_level
), you can specify the new fixture as an argument and use it to create a customer object. In this case, the change is minimal, but if the initialization step were more complex, it would significantly simplify the test function. We can also remove the line to import the Customer
class because we no longer use it in the test file.
def test_customer_has_initial_level(customer_factory): customer = customer_factory(100, 1) assert customer.cust_id == 100 assert customer.level == 1
The entire test file now looks like this:
test_customer6.py
def test_customer_has_default_properties(customer): print(f'{id(customer)=}') assert customer.cust_id == 100 assert customer.level == 0 def test_customer_has_initial_level(customer_factory): customer = customer_factory(100, 1) print(f'{id(customer)=}') assert customer.cust_id == 100 assert customer.level == 1 def test_customer_level_up_increases_level(customer): print(f'{id(customer)=}') original_level = customer.level customer.level_up() assert customer.level == original_level + 1 def test_customer_level_down_decreases_level(customer): print(f'{id(customer)=}') original_level = customer.level customer.level_up() customer.level_down() assert customer.level == original_level def test_customer_level_not_goes_below_0(customer): print(f'{id(customer)=}') original_level = customer.level customer.level_down() assert customer.level == (original_level - 1 if original_level >= 1 else 0)
The tests should still pass.
$ pytest test_customer6.py ================= test session starts ================== platform darwin -- Python 3.9.1, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 rootdir: /Users/mikio/pytest3 collected 5 items test_customer6.py ..... [100%] ================== 5 passed in 0.00s ===================
Fixtures can use another fixture, so in this example, we can use the new fixture in the first fixture, as shown below.
conftest.py
from customer import Customer import pytest @pytest.fixture(scope='session') def customer(customer_factory): _customer = customer_factory(100) return _customer @pytest.fixture(scope='session') def customer_factory(): def _customer(cust_id, level=0): _cust = Customer(cust_id, level) return _cust return _customer
Note that the fixture customer uses the other fixture customer_factory
to create a Customer
object. The tests still pass, and you can see that the object is shared across the test functions except for the second test, which creates a separate object.
$ pytest test_customer6.py -rP ================= test session starts ================== platform darwin -- Python 3.9.1, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 rootdir: /Users/mikio/pytest3 collected 5 items test_customer6.py ..... [100%] ======================== PASSES ======================== _________ test_customer_has_default_properties _________ ----------------- Captured stdout call ----------------- id(customer)=4387652800 ___________ test_customer_has_initial_level ____________ ----------------- Captured stdout call ----------------- id(customer)=4387653136 ________ test_customer_level_up_increases_level ________ ----------------- Captured stdout call ----------------- id(customer)=4387652800 _______ test_customer_level_down_decreases_level _______ ----------------- Captured stdout call ----------------- id(customer)=4387652800 _________ test_customer_level_not_goes_below_0 _________ ----------------- Captured stdout call ----------------- id(customer)=4387652800 ================== 5 passed in 0.01s ===================
How to Implement Fixture Teardown?
When using external resources in tests, it is essential to clean up when the tests finish (sometimes called teardown). For example, if you open a file, you should close the file descriptor. If you create a temporary file, you should delete it. If you connect to a database, you should disconnect from the database. In Pytest, it is easy to implement teardown logic when using fixtures.
Instead of using return, we can use yield to return the object after creating it in the fixture functions. When test functions finish using the fixture, the code after yield runs, so you write clean-up logic there.
There is nothing to clean up in our example, but let’s add print statements for demonstration purposes. In conftest.py
, update the function customer_factory()
as shown below.
conftest.py
from customer import Customer import pytest @pytest.fixture(scope='session') def customer(customer_factory): _customer = customer_factory(100) return _customer @pytest.fixture(scope='session') def customer_factory(): print('Fixture setup') def _customer(cust_id, level=0): _cust = Customer(cust_id, level) return _cust yield _customer print('Fixture teardown')
Note that the two print statements, one before the inner function and the other at the end. Also, the return is changed to yield in the function customer_factory()
.
When running Pytest, we can see the messages at the session’s beginning and end.
$ pytest test_customer6.py -rP ================= test session starts ================== platform darwin -- Python 3.9.1, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 rootdir: /Users/mikio/pytest3 collected 5 items test_customer6.py ..... [100%] ======================== PASSES ======================== _________ test_customer_has_default_properties _________ ---------------- Captured stdout setup ----------------- Fixture setup ----------------- Captured stdout call ----------------- id(customer)=4359226512 ___________ test_customer_has_initial_level ____________ ----------------- Captured stdout call ----------------- id(customer)=4359226848 ________ test_customer_level_up_increases_level ________ ----------------- Captured stdout call ----------------- id(customer)=4359226512 _______ test_customer_level_down_decreases_level _______ ----------------- Captured stdout call ----------------- id(customer)=4359226512 _________ test_customer_level_not_goes_below_0 _________ ----------------- Captured stdout call ----------------- id(customer)=4359226512 --------------- Captured stdout teardown --------------- Fixture teardown ================== 5 passed in 0.00s ===================
The setup and teardown run depending on the scope of the fixture. Let’s change the scope to function as shown below.
conftest.py
from customer import Customer import pytest @pytest.fixture(scope='function') def customer(customer_factory): _customer = customer_factory(100) return _customer @pytest.fixture(scope='function') def customer_factory(): print('Fixture setup') def _customer(cust_id, level=0): _cust = Customer(cust_id, level) return _cust yield _customer print('Fixture teardown')
We can see the setup and teardown messages before and after each test function runs.
$ pytest test_customer6.py -rP ================= test session starts ================== platform darwin -- Python 3.9.1, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 rootdir: /Users/mikio/pytest3 collected 5 items test_customer6.py ..... [100%] ======================== PASSES ======================== _________ test_customer_has_default_properties _________ ---------------- Captured stdout setup ----------------- Fixture setup ----------------- Captured stdout call ----------------- id(customer)=4387931376 --------------- Captured stdout teardown --------------- Fixture teardown ___________ test_customer_has_initial_level ____________ ---------------- Captured stdout setup ----------------- Fixture setup ----------------- Captured stdout call ----------------- id(customer)=4387931472 --------------- Captured stdout teardown --------------- Fixture teardown ________ test_customer_level_up_increases_level ________ ---------------- Captured stdout setup ----------------- Fixture setup ----------------- Captured stdout call ----------------- id(customer)=4387931520 --------------- Captured stdout teardown --------------- Fixture teardown _______ test_customer_level_down_decreases_level _______ ---------------- Captured stdout setup ----------------- Fixture setup ----------------- Captured stdout call ----------------- id(customer)=4387931280 --------------- Captured stdout teardown --------------- Fixture teardown _________ test_customer_level_not_goes_below_0 _________ ---------------- Captured stdout setup ----------------- Fixture setup ----------------- Captured stdout call ----------------- id(customer)=4387931472 --------------- Captured stdout teardown --------------- Fixture teardown ================== 5 passed in 0.01s ===================
Summary
In this article, we looked at how to use Pytest fixtures to initialize your test environment.Β
We first looked at how to create a Pytest fixture. You can create a fixture by creating a function with the @pytest.fixture
decorator. You can use the same fixtures in multiple test files if you define them in the file conftest.py
. You can also control the scope of fixtures by adding the scope argument.
We then looked at how to add function arguments to fixtures by using the Factory pattern.
Finally, we learned that adding teardown logic to fixtures is easy, which helps automate cleaning up the test environment.
Fixtures in Pytest are very powerful, and it is critical to understand how to use them if you want to run your tests efficiently. The functionality in this article should be enough to get you started, but you can find more information on the pytest website that might help you solve your specific requirements.
To boost your Python skills, feel free to join our free email academy: