Passing Functions to Test Files in Python Pytest

Author:Murphy  |  View: 23549  |  Time: 2025-03-22 21:34:56

PYTHON PROGRAMMING

Fixtures help reuse objects, including functions, in test functions. Photo by rivage on Unsplash

When you're using Pytest for Python unit testing, you can forward objects to test files using Pytest fixtures defined in conftest.py files. I used plural, since you can define any number of conftest.py files, and their location defines their scope.

When I was pretty new to unit testing in Python, I often wondered how I could define a helper function for testing and use it in selected testing functions. If you need this function in only one test file, no problem: simply define the function in this file and voilà, you can is it there.

What if you need to use the very same function in several testing files? One solution is to redefine the function in all these files – but that's definitely against the DRY (Don't Repeat Yourself) principle. How can this be done, then?

I've noticed many people were asking this question – hence this article. You'll see that if only you know the concept of pytest fixtures, the solution is very simple, even quite natural.

Fixtures

This article doesn't aim to introduce Pytest fixtures. As I'm planning to write a dedicated article to this subject, below you'll find only a very short introduction. If you'd like to learn more about them, you can do so from the pytest documentation:

About fixtures – pytest documentation

Simply put, you can use fixtures to pass various types of objects, mainly data and configuration, to test files. You need to define a fixture in a conftest.py file, and it will be available in all test files within the scope of this conftest.py file, that is, within the same folder in which the file is located and all of its subfolders.

Let's analyze a simple example. For the sake of simplicity, you can put the three below files in the same folder:

Python"># foo.py
def foo(x, y):
    return x + y
# conftest.py
import pytest

@pytest.fixture
def x_y_int():
    return 20, 50

@pytest.fixture
def x_y_str():
    return "20", "50"
# test_foo.py
import foo

def test_foo_ints(x_y_int):
    x, y = x_y_int
    assert foo.foo(x, y) == 70

def test_foo_ints(x_y_str):
    x, y = x_y_str
    assert foo.foo(x, y) == "2050"

The first module, foo, defines one function, foo(), which takes two arguments. In the conftest.py file, we define two fixtures, x_y_int and x_y_str. The former keeps integer values while the the latter string values for x and y.

As you can see, a fixture is a function. In order to use it in a test function, you need to provide it as this test function's argument the way we did above in test_foo.py. These two tests will pass:

  • test_foo_ints(x_y_int): We use the x_y_int fixture, which keeps a tuple of (20, 50). After unpacking it, we get x = 20 and y=50.
  • test_foo_str(x_y_str): We use the x_y_str fixture, which keeps a tuple of ("20", "50"). After unpacking it, we get x = "20" and y="50".

This is the very basic concept of using pytest fixtures. You can use them to pass any object to all the test files within the scope of the conftest.py file in which they are defined.

Passing a function to test files

I suppose that many tried to pass helper functions from conftest.py files in a regular Python way, that is, by importing them. Let's consider the following very simple example.

Imagine we're implementing unit tests for the following module:

# foomodule.py
def footuple(x):
    return x, x**2, x**3

We want to use the following helper function for Testing:

def is_tuple_fine(t: tuple) -> None:
    msgs = {
        "type": "It's not a tuple",
        "length": "Length of the tuple isn't 3",
        "values": "t[1] must be t[0]**2 and t[2] must be t[0]**3"
    }
    assert isinstance(t, tuple) is True, msgs["type"]
    assert len(t) == 3, msgs["length"]
    assert t[1] == t[0]**2 and t[2] == t[0]**3, msgs["values"]

The function performs three checks (assertions):

  • an object (t) is a tuple
  • its a tuple of three elements
  • the following conditions hold: t[1] == t[0]**2 and t[2] == t[0]**3

Below, I'll discuss three solutions.

Import the function from conftest

I discuss this solution first, as to many it will seem the most natural way of doing this: normally, when we need a function from another module, we import it, don't we? While this is true, this solution will work only in one specific scenario, so I don't consider it a good approach: since it isn't universal, it doesn't seem idiomatic. To be honest, I haven't seen it used in practice. Hence, I will discuss it, but only to show when it works. Definitely, don't use this approach.

Let's create a conftest.py file that contains this function and some other fixtures:

# conftest.py
import pytest

def is_tuple_fine(t: tuple) -> None:
    msgs = {
        "type": "It's not a tuple",
        "length": "Length of the tuple isn't 3",
        "values": "t[1] must be t[0]**2 and t[2] must be t[0]**3"
    }
    assert isinstance(t, tuple) is True, msgs["type"]
    assert len(t) == 3, msgs["length"]
    assert t[1] == t[0]**2 and t[2] == t[0]**3, msgs["values"]

@pytest.fixture
def ints_1():
    return 1

@pytest.fixture
def ints_2():
    return 2

Now, the test file:

# test_foomodule.py
import foomodule
from conftest import is_tuple_fine

def test_foo(ints_1):
    ints = foomodule.footuple(ints_1)
    is_tuple_fine(ints)
    assert ints == (1, 1, 1)

def test_foo_ints_2(ints_2):
    ints = foomodule.footuple(ints_2)
    is_tuple_fine(ints)
    assert ints == (2, 4, 8)

This solution will work only in one situation: when the conftest.py file is located in the very same folder in which the test file is located. The tests will pass.

However, when the conftest.py file is saved elsewhere (up the directory tree) – quite a frequent scenario – you'll see the following error:

Pytest failure after importing a function from the conftest.py file. Screenshot by author

This is why you should not consider this approach. Even if it works now, later you may need to change the structure of the tests, and then the tests will start failing (as shown above).

Define the helper function in the test file

It's actually the simplest scenario, and a good one in one situation: when you need the function to be available in only one test file. Then, it's enough to define the function in this particular test file – no need to do this in conftest.py. This would mean moving the definition of is_tuple_fine() to the test_foo module.

However, if you need the helper function to be available in more than just one test file, you'd have to redefine it other files – an approach definitely to be avoided, as it violates the DRY (Don't Repeat Yourself) rule. This is when the next solution comes handy.

Pass the function as a fixture

This is a universal solution, as this is exactly what fixtures were created for: passing objects to test files.

How to do this? Let me repeat a sentence I used above:

You can use them [fixtures] to pass any object to all the test files within the scope of the conftest.py file in which they are defined.

Any object, so why not a function? In Python, a function is an object just like any other object!

This is how I usually do this. I define the function I need as a private function (so, prefixed with an underscore), that is, _is_tuple_fine(), in the conftest.py file. Then, I define a public fixture with the actual name of the function, that is, is_tuple_fine(). So:

# conftest.py
import pytest

def _is_tuple_fine(t: tuple) -> None:
    msgs = {
        "type": "It's not a tuple",
        "length": "Length of the tuple isn't 3",
        "values": "t[1] must be t[0]**2 and t[2] must be t[0]**3"
    }
    assert isinstance(t, tuple) is True, msgs["type"]
    assert len(t) == 3, msgs["length"]
    assert t[1] == t[0]**2 and t[2] == t[0]**3, msgs["values"]

@pytest.fixture(scope="session")
def is_tuple_fine():
    return _is_tuple_fine

@pytest.fixture
def ints_1():
    return 1

@pytest.fixture
def ints_2():
    return 2

When the actual function is very short, you can define it inside the fixture. Choose the cleaner and more readable approach.

Note the "session" scope. The default scope Pytest fixtures is "function", meaning that the fixture is destroyed at the end of each test function. This particular fixture can work throughout the whole session, so there's no need to destroy it in the meantime. You can read more about fixture scope in the Pytest documentation.

Now, to use our function in test files, you need to use it just like any other fixture:

import foomodule

def test_foo_ints_1(ints_1, is_tuple_fine):
    ints = foomodule.footuple(ints_1)
    is_tuple_fine(ints)
    assert ints == (1, 1, 1)

def test_foo_ints_2(ints_2, is_tuple_fine):
    ints = foomodule.footuple(ints_2)
    is_tuple_fine(ints)
    assert ints == (2, 4, 8)

As you see, you need to use the is_tuple_fine fixture as an argument to each test function in which you want to use it. Then, it will become a function that you need.

Conclusion

When you know the third approach, it looks quite natural and idiomatic. And it is – because this is exactly what Pytest fixtures were designed for, that is, to pass objects from conftest.py files to test files.

When you don't know you can do it that way, it's natural to think of importing helper functions. One time it will work, but another it won't – and you may lose some time pondering what's going on.

Nothing is going on. This is how Pytest works. Now that you know what to do, I am pretty sure you won't have any problems with passing helper functions form conftest.py files. With this tool, Pytest testing is even simpler.

Tags: Data Science Pytest Python Testing Unit Testing

Comment