When to use pytest fixtures?

Both Pytest Fixtures and regular functions can be used to structure to test code and reduce code duplication. The answer provided by George Udosen does an excellent job explaining that.

However, the OP specifically asked about the differences between a pytest.fixture and a regular Python function and there is a number of differences:

Pytest Fixtures are scoped

By default, a pytest.fixture is executed for each test function referencing the fixture. In some cases, though, the fixture setup may be computationally expensive or time consuming, such as initializing a database. For that purpose, a pytest.fixture can be configured with a larger scope. This allows the pytest.fixture to be reused across tests in a module (module scope) or even across all tests of a pytest run (session scope). The following example uses a module-scoped fixture to speed up the tests:

from time import sleep
import pytest


@pytest.fixture(scope="module")
def expensive_setup():
    return sleep(10)

def test_a(expensive_setup):
    pass  # expensive_setup is instantiated for this test

def test_b(expensive_setup):
    pass  # Reuses expensive_setup, no need to wait 10s

Although different scoping can be achieved with regular function calls, scoped fixtures are much more pleasant to use.

Pytest Fixtures are based on dependency injection

Pytest registers all fixtures during the test collection phase. When a test function requires an argument whose name matches a registered fixture name, Pytest will take care that the fixture is instantiated for the test and provide the instance to the test function. This is a form of dependency injection.

The advantage over regular functions is that you can refer to any pytest.fixture by name without having to explicitly import it. For example, Pytest comes with a tmp_path fixture that can be used by any test to work with a temporary file. The following example is taken from the Pytest documentation:

CONTENT = "content"


def test_create_file(tmp_path):
    d = tmp_path / "sub"
    d.mkdir()
    p = d / "hello.txt"
    p.write_text(CONTENT)
    assert p.read_text() == CONTENT
    assert len(list(tmp_path.iterdir())) == 1
    assert 0

The fact that users don’t have to import tmp_path before using it is very convenient.

It is even possible to apply a fixture to a test function without the test function requesting it (see Autouse fixtures).

Pytest fixtures can be parametrized

Much like test parametrization, fixture parametrization allows the user to specify multiple “variants” of a fixture, each with a different return value. Every test using that fixture will be executed multiple times, once for each variant. Say you want to test that all your code is tested for HTTP as well as HTTPS URLs, you might do something like this:

import pytest


@pytest.fixture(params=["http", "https"])
def url_scheme(request):
    return request.param


def test_get_call_succeeds(url_scheme):
    # Make some assertions
    assert True

The parametrized fixture will cause each referencing test to be executed with each version of the fixture:

$ pytest
tests/test_fixture_param.py::test_get_call_succeeds[http] PASSED                                                                                                                                                                         [ 50%]
tests/test_fixture_param.py::test_get_call_succeeds[https] PASSED                                                                                                                                                                        [100%]

======== 2 passed in 0.01s ========

Conclusion

Pytest fixtures provide many quality-of-life improvements over regular function calls. I recommend to always prefer Pytest fixtures over regular functions, unless you must be able to call the fixture directly. Directly invoking pytest fixtures is not indended and the call will fail.

Leave a Comment