How Pytest Fixtures Can Help You Write More Readable And Efficient Tests
When writing tests, it’s often necessary to set up some initial state before running the actual test code.
This setup can be time-consuming to write, especially when there are multiple tests that require the same steps.
Fixtures in Pytest solve some of the problems of code duplication and boilerplate.
They help you define reusable setup or teardown code that can be used across multiple tests.
Instead of duplicating the same setup in every test, a fixture can be defined once and used in multiple tests.
What Are Pytest Fixtures
Fixtures are methods in Pytest that provide a fixed baseline for tests to run on top of.
A fixture can be used to set up preconditions for a test, provide data, or perform a teardown after a test is finished.
They are defined using the @pytest.fixture
decorator in Python and can be passed to test functions as arguments.
Unit Tests
The unit tests are defined in 2 separate files
-
test_calculator_class.py
— Tests the Calculator Class -
test_calculator_api.py
— Tests the API Endpoints with custom payload
Fixtures In The Test
The easiest way to define fixtures, is within the test itself. Let’s see how we can test our code using fixtures defined within the test.
# test_calculator_class.py
import pytest
from calculator.core import Calculator
@pytest.fixture
def calculator():
return Calculator(2, 3)
```python
def test_add(calculator):
assert calculator.add() == 5
def test_subtract(calculator):
assert calculator.subtract() == -1
def test_multiply(calculator):
assert calculator.multiply() == 6
def test_divide(calculator):
assert calculator.divide() == 0.6666666666666666
def test_divide_by_zero(calculator):
calculator.b = 0
with pytest.raises(ZeroDivisionError):
calculator.divide()
Here we’ve defined the Pytest fixture using the @pytest.fixture
decorator.
We initialised the Calculator
class with the values (2,3) and returned an instance of the class.
Let’s look at how we can do this for the API too.
# test_calculator_api.py
import pytest
from app.app import app
@pytest.fixture
def client():
with app.test_client() as client:
yield client
@pytest.fixture
def json_headers():
return {"Content-Type": "application/json"}
def test_add(client, json_headers, json_data):
response = client.post("/api/add/", headers=json_headers, json=json_data)
assert response.status_code == 200
assert response.json == 3
def test_subtract(client, json_headers, json_data):
response = client.post("/api/subtract/", headers=json_headers, json=json_data)
assert response.status_code == 200
assert response.json == -1
def test_multiply(client, json_headers, json_data):
response = client.post("/api/multiply/", headers=json_headers, json=json_data)
assert response.status_code == 200
assert response.json == 2
def test_divide(client, json_headers, json_data):
response = client.post("/api/divide/", headers=json_headers, json=json_data)
assert response.status_code == 200
assert response.json == 0.5
def test_divide_by_zero(client, json_headers):
response = client.post("/api/divide/", headers=json_headers, json={"a": 1, "b": 0})
assert response.status_code == 400
assert response.json == "Cannot divide by zero"
In this test, we define 2 fixtures
-
The Flask Client
-
JSON headers for the API request
Fixtures Across Multiple Tests via Conftest
A more efficient way is to stick common fixtures in a file called conftest.py
where all unit test files will pick them up automatically.
import pytest
from calculator.core import Calculator
from app.app import app
@pytest.fixture(scope="module")
def calculator():
return Calculator(2, 3)
@pytest.fixture
def custom_calculator(scope="module"):
def _calculator(a, b):
return Calculator(a, b)
return _calculator
@pytest.fixture(scope="module")
def client():
with app.test_client() as client:
yield client
@pytest.fixture(scope="module")
def json_headers():
return {"Content-Type": "application/json"}
@pytest.fixture(scope="module")
def json_data():
return {"a": 1, "b": 2}
Parameterized Fixtures
These are fixtures that can accept one or more arguments and be initialised at run time.
In the code sample from the previous block, we defined the custom_calculator
fixture that allows us to pass different values of (a, b) within the tests.
Fixture Dependency Injection
Fixtures can also be called (or requested) by other fixtures. This is called dependency injection.
Like:
import pytest
class MyObject:
def __init__(self, value):
self.value = value
@pytest.fixture
def my_object():
return MyObject(“Hello, World!”)
def test_my_object(my_object):
assert my_object.value == “Hello, World!”
@pytest.fixture
def my_dependent_object(my_object):
return MyObject(my_object.value + “ Again!”)
def test_my_dependent_object(my_dependent_object):
assert my_dependent_object.value == “Hello, World! Again!”
Auto Using Fixtures
If you’re looking for a simple trick to avoid defining the fixture in each test, you can use the autouse=True
flag as an argument in the fixture definition.
Fixture Scopes
Fixture scopes define the lifetime and visibility of fixtures.
The scope of a fixture determines how many times it will be called and how long it will live during the test session.
-
function
-
class
-
module
-
session
Yield vs Return in Fixtures
In general, yield
is often used when you need to set up and tear down some resources for each test function, while return
is used when you only need to provide a simple value to the test function.
Where Should You Use Fixtures
Generally, a good use case for fixtures are
-
Clients — Database clients, AWS or other cloud clients, API clients which require setup/teardown
-
Test Data — Test data in JSON or another format can be easily imported and shared across tests
-
Functions — Some commonly used functions can be used as fixtures