In the previous article, I briefly talked about fixtures in pytest — what they are and how to use them. However, when you start using them frequently and across a large number of tests, you begin to think about how to use them more efficiently or with less boilerplate code.
Let’s look at how you can work with fixtures when using a class-based test structure.
As in the documentation
For example, according to the documentation, it is recommended to do something like this:
import pytest
@pytest.fixture
def method_fixture():
print("method fixture")
@pytest.fixture(scope="class")
def class_fixture():
print("class fixture")
@pytest.fixture(scope="session")
def session_fixture():
print("session fixture")
class TestClass:
def test_1(self, method_fixture, session_fixture):
pass
def test_2(self, method_fixture, session_fixture, class_fixture):
pass
def test_3(self, method_fixture, class_fixture):
pass
def test_4(self, class_fixture, session_fixture):
pass
def test_5(self, method_fixture, session_fixture):
pass
Pros of this approach:
- fully aligned with the documentation
- completely clear to anyone who has read the pytest documentation
Cons:
- repeatedly listing the same fixtures
- with a large number of fixtures, there will be many parameters (linters may complain)
- you must explicitly pass all fixtures into various functions inside the class
Using a method as a fixture
You can define a method as a fixture; it will have access to self, where you can attach all your fixtures:
class TestClassSelfFixtures:
@pytest.fixture(autouse=True)
def save_fixture(self, method_fixture, session_fixture, class_fixture):
self.method_fixture = method_fixture
self.session_fixture = session_fixture
self.class_fixture = class_fixture
def test_6(self):
self.method_fixture()
def test_7(self):
self.class_fixture()
Pros:
- makes sense from an OOP perspective
- a single place to add fixtures
Cons:
- introduces one more fixture
- the fixture will run for every test function and reattach attributes each time (a tiny overhead, but still)
- adding new attributes outside
__init__may cause linters to complain - all the usual OOP inheritance downsides apply
Using request
Pytest provides the request fixture, which contains environment objects, the current test, and the current class — and this can be leveraged.
@pytest.fixture(scope="class")
def attach_to_class(request):
request.cls.class_fixture = class_fixture
@pytest.mark.usefixtures("attach_to_class")
class TestClassRequest:
def test_8(self):
print(self.class_fixture)
Here, the request fixture has a cls attribute, which represents our test class object, and we attach our fixture as an attribute to it. With @pytest.mark.usefixtures, we explicitly specify the list of fixtures we need.
Pros:
- executed once per class
- follows the pytest philosophy
- clean code
- linters do not complain (especially if you declare the attribute at the class level)
Cons:
- slightly more complex logic, so you need to be more careful
- the logic is hidden inside the fixture rather than in the class itself
Which approach I use
In our test codebase, over time, we settled on the last approach, but we use it only for class-level or higher-scope fixtures. For scope="function", we explicitly pass the fixture into each test.