How to Use Pytest Fixtures

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)!

  1. Pytest – A Complete Overview
  2. Pytest – How to Run Tests Efficiently

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:

  1. Import Pytest into the test file.
import pytest
  1. Create a function with the @pytest.fixture decorator.
@pytest.fixture
def customer():
    _customer = Customer(100)
    return _customer
  1. 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: