Introduction

Pytest is a popular testing framework for Python that provides many features and benefits for writing and running tests. One of the most powerful features of pytest is its fixture system, which allows you to create reusable and modular setup and teardown logic for your tests. In this article, you will learn what fixtures are, how to use them, and how they can improve your testing workflow. You will also see some examples of common fixtures and how to parametrize them for different scenarios. By the end of this article, you will have a solid understanding of pytest fixtures and how to apply them in your own projects.

Writing Fixtures

Fixtures are functions that provide setup data or resources for your test functions. They are defined using the @pytest.fixture decorator. Let's start with a simple fixture example.

Create a new file called test_calculate.py and paste the script below.

Note: Pytest uses a naming convention to automatically find and run tests. Test files should start with the word "test_" or end with the suffix "test.py". This convention helps you to organize your tests in a logical way and keep them separate from your application code. Test functions should also start with the word "test". This makes it easy for pytest to identify and run them automatically.

import pytest

@pytest.fixture
def my_fixture():
    return 5

Let's break down what each part of the code does:

  • import pytest: Import the pytest library.

  • @pytest.fixture: Decorate a function to define a fixture.

  • def my_fixture(): Create a fixture function.

  • return 5: The fixture function returns the integer 5.

You can use the my_fixture fixture in your test functions by including it as an argument to the test functions that need it. For example:

def test_fixture(my_fixture):
    assert my_fixture == 5

To execute the test simply run pytest -v or pytest test_calculate.py -v from the command prompt.

Output:

More Examples:

Fixtures can be used with classes and even for handling data loading and modification. Here are a few more examples:

Using pytest fixtures with a class:

import pytest

class Animal:
    def __init__(self, name):
        self.name = name
        self.is_hungry = True

    def eat(self):
        self.is_hungry = False

class Zoo:
    def __init__(self, *animals):
        self.animals = animals
        self._feed_animals()

    def _feed_animals(self):
        for animal in self.animals:
            animal.eat()


@pytest.fixture
def zoo_animals():
    return [Animal("lion"), Animal("tiger")]

def test_zoo(zoo_animals):
    # Act
    zoo = Zoo(*zoo_animals)

    # Assert
    assert not any(animal.is_hungry for animal in zoo.animals)

In this example, the test_zoo function "requests" the zoo_animals fixture by defining it as an argument (i.e., def test_zoo(zoo_animals):). When you run this test using Pytest, Pytest recognizes that test_zoo depends on the zoo_animals fixture. Therefore, Pytest automatically executes the zoo_animals fixture function and passes the object it returns (in this case, a list of Animal objects) as the zoo_animals argument to the test_zoo function. This allows you to use the zoo_animals list within the test to perform your assertions and validations.

Assuming you have a data.json file with some dummy data like this:

{
    "users": [
        {"id": 1, "username": "user1"},
        {"id": 2, "username": "user2"},
        {"id": 3, "username": "user3"}
    ]
}

If you want to test the functionality without using a fixture and instead load the data directly within each test function, you can do so like this:

import json

# Test function to load data from data.json directly
def test_fetch_data_without_fixture():
    with open("data.json", "r") as file:
        data = json.load(file)

    users = data.get("users", [])

    # Perform assertions based on the loaded data
    assert len(users) == 3
    assert any(user["username"] == "user1" for user in users)

def test_modify_data_without_fixture():
    with open("data.json", "r") as file:
        data = json.load(file)

    users = data.get("users", [])

    # Modify the loaded data
    users.append({"id": 4, "username": "user4"})

    # Perform assertions based on the modified data
    assert len(users) == 4

This approach involves loading data directly in each test instead of using fixtures. This leads to code duplication if multiple tests require the same data. Additionally, this approach does not offer the benefits of fixture reuse. For example, if you have to test thousands of users, this approach may lead to resource wastage.

Using fixtures is recommended when you have shared resources or setup/teardown logic that should be reused across multiple tests to keep your test code clean and maintainable.

import pytest
import json

# Define a fixture to load dummy data from data.json
@pytest.fixture
def dummy_data():
    with open("data.json", "r") as file:
        data = json.load(file)
    return data

# Test function that uses the dummy_data fixture
def test_fetch_data(dummy_data):
    users = dummy_data.get("users", [])

    # Perform assertions based on the dummy data
    assert len(users) == 3
    assert any(user["username"] == "user1" for user in users)

def test_modify_data(dummy_data):
    users = dummy_data.get("users", [])

    # Modify the dummy data
    users.append({"id": 4, "username": "user4"})

    # Perform assertions based on the modified data
    assert len(users) == 4

Output:

Fixture Scope Autouse and Name parameters

Scope (scope parameter):

The scope parameter determines how long a fixture should live and when it should be created and finalized. The available scope options are:

  • "function" (default): The fixture is created and finalized for each test function that uses it. This is the most common scope and ensures that each test function gets a fresh instance of the fixture.

  • "class": The fixture is created and finalized once for each test class. All test methods within the same class share the same instance of the fixture.

  • "module": The fixture is created and finalized once per test module. All test functions within the same module share the same instance of the fixture.

  • "session": The fixture is created and finalized once for the entire test session. All test functions across all modules and classes share the same instance of the fixture.

The choice of scope depends on your specific testing needs. Use function scope for most cases, and consider class, module, or session scope when you want to share fixture instances across multiple tests for efficiency.

Autouse (autouse parameter):

The autouse parameter is a boolean flag that, when set to True, indicates that the fixture should be automatically used by all tests, even if they don't explicitly request it as an argument. When set to True, pytest will automatically call the fixture for every test within the same scope. This can be useful for fixtures that perform setup and teardown actions that are required for all tests within a certain context.

Name (name parameter):

The name parameter allows you to provide a custom name for your fixture. By default, pytest uses the name of the fixture function as its identifier. However, you can specify a different name using the name parameter. This can help improve the readability of your test reports, especially when you have multiple fixtures in your test suite.

@pytest.fixture(scope="module", autouse=True, name="my_numbers")
def numbers():
    return [1, 2, 3]

These parameters provide flexibility and control over how your fixtures are utilized within your test suite, allowing you to tailor their behavior to suit your specific testing requirements.

Fixture dependency

When one fixture depends on another fixture in pytest, it's often referred to as "fixture dependency" or "fixture chaining". Fixture dependency allows you to build a hierarchy of fixtures where one fixture relies on the setup provided by another fixture. This is a powerful feature that helps in creating modular and reusable fixture components, where lower-level fixtures can be combined to create more complex setups for your tests.

To use one fixture inside another fixture, you simply need to specify the name of the fixture you want to use as a parameter of the fixture function that uses it. For example, if you have a fixture that returns a list of numbers, and another fixture that returns the sum of those numbers, you can write something like this:

import pytest

# Arrange
@pytest.fixture
def numbers():
    return [1, 2, 3]

# Arrange
@pytest.fixture
def total(numbers):
    return sum(numbers)

def test_total(total):
    # Assert
    assert total == 6

Pytest will resolve the fixture dependencies by looking at the names of the parameters and matching them with the names of the fixtures. It will then execute the fixtures in the order of their dependency, starting from the ones that do not depend on any other fixtures. For example, in the code above, pytest will do something like this:

  • Execute the numbers fixture and store its return value.

  • Execute the total fixture with the return value of numbers as its argument and store its return value.

  • Execute the test_total function with the return value of total as its argument and run the assertion.

You can see the order of fixture execution by using the --setup-show option when running pytest.

Output:

Fixture FInalization

Fixture finalization in pytest refers to the cleanup or teardown actions that are performed after a fixture is used in a test function. When a test function finishes executing, pytest ensures that any associated fixture finalization code is executed to clean up any resources or perform necessary cleanup tasks. This ensures that resources are properly released, and the system is left in a consistent state after the test has completed.

To perform cleanup actions after a fixture is used, you can use the yield statement. This method allows you to define the finalization code that will be executed after the fixture value is returned to the test or another fixture.

The yield statement is a simpler and recommended way to write a fixture with finalization. It works like this:

  1. Define a fixture function with the @pytest.fixture decorator.

  2. Write the setup code that creates the fixture value or object.

  3. Use the yield keyword instead of return to pass the fixture value or object to the test or another fixture.

  4. Write the finalization code that cleans up the fixture value or object after the yield statement.

import pytest

# Fixture for setting up a temporary file
@pytest.fixture
def temp_file():
    file = open("temp.txt", "w")
    yield file
    file.close()  # This is the fixture finalization step

def test_write_to_temp_file(temp_file):
    temp_file.write("Test data")
    assert temp_file.tell() == 9

# The fixture finalization will ensure the file is closed, preventing resource leaks

In this example, the temp_file fixture sets up a temporary file for writing data. After the test function test_write_to_temp_file completes, the fixture finalization step is automatically executed (file.close()), ensuring that the file is properly closed.

Output:

Fixture finalization is a crucial aspect of pytest that helps maintain the cleanliness and reliability of your test suite, particularly when dealing with resource-intensive or complex setups.

Parametrized Fixtures

To create a fixture that can take multiple values and run a test for each value, you can use the params parameter of the @pytest.fixture decorator. This allows you to define a list of values that the fixture will iterate over and pass to the test function.

The params parameter of the @pytest.fixture decorator works like this

  • Define a fixture function with the @pytest.fixture decorator and specify a list of values as the params argument. For example, if you want to create a fixture that returns different numbers, you can write something like this:
import pytest

# Arrange
@pytest.fixture(params=[1, 2, 3])
def number(request):
    return request.param
  • The request parameter is a special fixture that provides information about the current test and fixture request. You can use the request.param attribute to access the current value from the params list.

  • To use the fixture in a test function, you simply need to have a parameter with the same name as the fixture. For example, if you want to use the`number` fixture defined above in a test function, you can write something like this:

def test_is_odd(number):
    # Assert
    assert number % 2 == 1

Pytest will generate multiple tests from a single test function and a parametrized fixture by creating a new test instance for each value in the params list. It will then inject the value into the fixture function and pass it to the test function.

Output:

You can see that pytest creates three tests with different names based on the fixture value: test_is_odd[1], test_is_odd[2], and test_is_odd[3]. It also shows the setup and teardown of each fixture instance and the result of each test.

Conclusion

Pytest fixtures are a powerful feature that allows you to set up and manage your testing environment efficiently. You can use fixtures for setup, teardown, and even parameterization of tests. Understanding how to use fixtures effectively can greatly improve the organization and maintainability of your test suite.