Skip to content

#49: Creating Your Own Fixtures for Pytest

While built-in fixtures are a great help, they cannot address the specific needs of our applications. It is now time to have a closer look on how to create our own fixtures.

Why not just setup and teardown?

Other testing frameworks have the concept of setup and teardown methods to get your system in a known state. They can be per class or sometimes even for the whole test suite (like SetUpFixture for NUnit in .Net). The downside with this approach is that the setup part is often too specific to reuse it, or it runs for tests that do not need it.

With fixtures in pytest you control where they run and what they do. You can use a fixture in a test that needs it and not use it in the other tests in the same file. Since a fixture is not bound to a file/module, you can use it throughout your test suite.

Reduce duplication

If we have tests that need the same setup code, we will end up with a lot of duplication. The bigger and more complex the creation of our objects get, the more code we write in all our tests.

For the examples on fixtures I use the Phonebook class from the Pluralsight course "Unit Testing with Python" by Emily Bache extended with some additional complexity. The code we want to test is in the file contact.py:

class Datastore:
    def __init__(self, directory=None):
        self.collection = {}

    def store(self):
        return self.collection


class Phonebook:
    def __init__(self, storage):
        self.numbers = storage.store()
        print("Phonebook created")

    def add(self, name, number):
        self.numbers[name] = number

    def lookup(self, name):
        return self.numbers[name]

    def names(self):
        return set(self.numbers.keys())

    def clear(self):
        print("Phonebook removed")

The two tests I have use half of their method to create a Phonebook instance:

from contact import Phonebook, Datastore

def test_add_contact_to_phonebook():
    store = Datastore()
    phonebook = Phonebook(store)
    phonebook.add("Andy", 12345)    
    assert "Andy" in phonebook.names()


def test_lookup_contact_to_phonebook():
    store = Datastore()
    phonebook = Phonebook(store)
    phonebook.add("Mandy", 45678)    
    assert 45678 == phonebook.lookup("Mandy")

We can move the duplicated code in its own function and use the decorator @pytest.fixture to tell pytest that this is a fixture:

1
2
3
4
5
6
7
import pytest

@pytest.fixture()
def phonebook():
    store = Datastore()
    phonebook = Phonebook(store)
    return phonebook

As with the built-in ones, we can now pass this fixture as an argument to our test functions and get rid of the duplication:

1
2
3
4
5
6
7
8
def test_add_contact_to_phonebook(phonebook):
    phonebook.add("Andy", 12345)    
    assert "Andy" in phonebook.names()


def test_lookup_contact_to_phonebook(phonebook):
    phonebook.add("Mandy", 45678)    
    assert 45678 == phonebook.lookup("Mandy")

Clean-up after our fixture

Often we need to do some clean-up after we run a test. While we could create another fixture, pytest has a better approach: we can use the yield statement in our fixture to turn it into a generator (as explained in the last post ). This means pytest can leverage the features of Python and our code is simpler to understand when the setup and clean-up code is in the same place:

1
2
3
4
5
6
@pytest.fixture()
def phonebook():
    store = Datastore()
    phonebook = Phonebook(store)
    yield phonebook
    phonebook.clear()

Scopes for our fixture

By default, pytest creates an instance of our fixture for every test function:

$ pytest -s .\test_own_fixture.py

===================== test session starts ======================
platform win32  Python 3.8.1, pytest-5.4.3, py-1.9.0, pluggy-0.13.1
rootdir: D:\Python
plugins: cov-2.10.1, html-2.1.1, metadata-1.10.0
collected 2 items

test_own_fixture.py Phonebook created
.Phonebook removed
Phonebook created
.Phonebook removed

We can change this behaviour with a scope-argument to the decorator. Valid values for scope are "function" (default), "class", "module" and "session".

1
2
3
4
5
6
@pytest.fixture(scope="module")
def phonebook():
    store = Datastore()
    phonebook = Phonebook(store)
    yield phonebook
    phonebook.clear()
$ pytest -s .\test_own_fixture.py

===================== test session starts ======================
platform win32  Python 3.8.1, pytest-5.4.3, py-1.9.0, pluggy-0.13.1
rootdir: D:\Python
plugins: cov-2.10.1, html-2.1.1, metadata-1.10.0
collected 2 items

test_own_fixture.py Phonebook created
..Phonebook removed

Using fixtures inside other fixtures

We can use fixtures not only in our test functions, but also in our fixtures. If for example our phonebook needs a temporary directory to save its entries, we can use tmpdir as a parameter:

1
2
3
4
5
6
7
@pytest.fixture()
def phonebook(tmpdir):
    "Creates a Phonebook instance"
    store = Datastore(tmpdir)
    phonebook = Phonebook(store)
    yield phonebook
    phonebook.clear() 

However, there is a little catch we need to understand: All fixtures must have the same scope. If we want to scope our own fixture to span the whole module and we use tmpdir that has the scope set to function, we will get an error like this:

1
2
3
4
5
6
7
@pytest.fixture(scope="module") #Error!
def phonebook(tmpdir):
    "Creates a Phonebook instance"
    store = Datastore(tmpdir)
    phonebook = Phonebook(store)
    yield phonebook
    phonebook.clear() 
$ pytest -s .\test_own_fixture.py

...
================== ERRORS ==================
_______ ERROR at setup of test_add_contact_to_phonebook ________
ScopeMismatch: You tried to access the function scoped fixture tmpdir 
with a module scoped request object, involved factories
test_own_fixture.py:19: def phonebook(tmpdir)
__

Reuse our fixtures

The great benefit of fixtures is that we can reuse them in all our tests. However, if we try to use it in another file, we get this error:

__ ERROR at setup of test_add_multiple_contacts_to_phonebook ___
file D:\Python\test_own_fixture2.py, line 3
def test_add_multiple_contacts_to_phonebook(phonebook):
E fixture ‘phonebook’ not found
> available fixtures: cache, capfd, capfdbinary, caplog, ...

The dependency injection part of pytest does not know where our fixture comes from. If we run all our tests it could be found, but what happens if we only want to run one test file? The solution to this problem is that we move our fixtures in a file called conftest.py directly in the test folder:

1
2
3
4
5
6
7
8
9
from contact import Phonebook, Datastore
import pytest

@pytest.fixture()
def phonebook(tmpdir):
    store = Datastore(tmpdir)
    phonebook = Phonebook(store)
    yield phonebook
    phonebook.clear() 

Now pytest finds your fixture independently of what test you run:

$ pytest -s .\test_own_fixture2.py

===================== test session starts ======================
platform win32  Python 3.8.1, pytest-5.4.3, py-1.9.0, pluggy-0.13.1
rootdir: D:\Python
plugins: cov-2.10.1, html-2.1.1, metadata-1.10.0
collected 1 item

test_own_fixture2.py Phonebook created
.Phonebook removed

====================== 1 passed in 0.03s =======================

List your own fixtures

The more you create fixtures, the less you remember exactly how you named them. Pytest helps us to find what we need by including our own fixtures in the output of --fixtures:

$ pytest --fixtures

...
———— fixtures defined from conftest————
phonebook
      conftest.py:5: no docstring available

As a last change on our fixture we should add a little bit of documentation:

1
2
3
4
5
6
7
@pytest.fixture()
def phonebook(tmpdir):
    "Creates a Phonebook instance"
    store = Datastore(tmpdir)
    phonebook = Phonebook(store)
    yield phonebook
    phonebook.clear()

This little change will make working with our fixture a lot simpler:

$ pytest --fixtures

...
———— fixtures defined from conftest————
phonebook
      Creates a Phonebook instance

Next

Now that we can create our fixtures it is time to look at markers and how we can use them to run a subset of our tests.