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.

  1. function

  2. class

  3. module

  4. 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