Fixtures: reusable setup, handed to a test by name#

← Back to Real pytest

What a fixture actually is#

Strip away the decorator and a fixture is just a function. funded_wallet above does nothing you couldn't do by hand at the top of every test: build a Wallet, deposit some starting balance into it, and hand it back. The only thing @pytest.fixture adds is telling pytest "this function is available to be requested by tests," so that setup logic lives in exactly one place instead of being retyped, slightly differently, at the top of every test that needs a funded wallet.

This matters more as a test suite grows. Ten tests that each need a wallet with a starting balance either repeat that setup ten times (and drift out of sync the moment one copy gets updated and the other nine don't), or share one fixture that all ten ask for by name. The second option is the entire reason fixtures exist.

Dependency injection by parameter name, not magic#

The mechanism is worth being precise about, because it looks like magic the first time you see it and isn't. test_withdraw_reduces_balance(funded_wallet) doesn't call a function named funded_wallet; it declares a parameter named funded_wallet. When pytest is about to run that test, it looks at the test function's parameter list, sees the name funded_wallet, checks whether any fixture with that exact name is registered (in the same file, or in a conftest.py covering it, more on that below), calls that fixture function, and passes whatever it returned in as the argument. Rename the fixture, or the parameter, and the match breaks; there's no other wiring involved. That's the entirety of what people mean by "pytest's dependency injection": a name match between a test's parameter and a fixture's name, resolved automatically before the test body ever runs.

Scope: how long a fixture's result sticks around#

By default, a fixture is function-scoped: pytest reruns it, from scratch, for every single test function that asks for it. That's what test_a_second_test_gets_its_own_wallet above is demonstrating; even though both tests ask for funded_wallet, each gets its own fresh Wallet instance, so the first test's withdrawal never shows up in the second test's balance. This is almost always what you want, since a test that accidentally depends on another test having run first (and in a particular order) is a fragile test.

Sometimes rebuilding a fixture's setup for every single test is wasteful, expensive setup, a real database connection, something slow to construct, in which case @pytest.fixture(scope="module") reuses the same result for every test in that one file, or @pytest.fixture(scope="session") reuses it for the entire test run, across every file. Both trade isolation for speed: a module- or session-scoped fixture's result is shared, so a test that mutates it in place can leak that mutation into a later test, the exact bug function scope prevents by default. Start with the default (function scope, meaning: don't even write scope=) unless you have a specific, understood reason to widen it.

Sharing a fixture across files: conftest.py#

A fixture defined directly in a test file is only visible inside that file. Once a suite has more than one test file that needs the same setup (in this program's project step, that's going to be "a freshly deployed AssetRegistry," needed by nearly every test in the file, and potentially by more than one file), retyping the fixture into each file is exactly the duplication problem fixtures exist to solve, just one level up.

pytest's answer is a specially named file, conftest.py, placed in a folder alongside your test files. Any fixture defined inside conftest.py becomes automatically available to every test in that same folder, and every folder below it, with no import statement needed at all; pytest loads conftest.py files itself, as part of collection, specifically so this sharing works without you wiring it up by hand. You won't need this for the two small exercises in exercises/, since each one is a single, self-contained file, but it's worth knowing the name now: the real project step in project/README.md is exactly the kind of growing, multi-file suite conftest.py is for.

What to watch for#

  • A fixture and a test parameter with mismatched names. There's no error at the point of the typo; pytest just won't find a matching fixture, and you'll get a fixture 'funded_walet' not found-style error instead, naming exactly the misspelled name it went looking for.
  • Reaching for a wider scope (module or session) purely to make tests run faster, before you've confirmed the tests genuinely don't mutate the fixture's result. That's the classic way isolation quietly breaks.
  • Forgetting that a fixture has to return (or yield, for setup that needs cleanup afterward, a detail you won't need for this module's exercises or project) its value. A fixture function that does its setup but never hands anything back leaves the requesting test's parameter as None.

TL;DR#

This page explains what a pytest fixture actually is, how a test asks for one just by naming it as a parameter, and how far that reused piece of setup is allowed to live.

  • A fixture is a function that produces a piece of ready-to-use setup (an object, a value, a connection, anything) for a test to use, so the test itself doesn't have to build that setup by hand every time.
  • @pytest.fixture marks a function as one. A test asks for it by adding a parameter with that fixture's exact name; pytest sees the parameter name, matches it to the fixture, calls the fixture, and hands the test whatever it returned. That's the entire injection mechanism, nothing more implicit than a name match.
  • By default, a fixture is function-scoped: pytest calls it fresh for every single test that asks for it, so one test's changes to what it got back never leak into another test's copy.
  • A fixture defined in a file named conftest.py is available to every test file in that folder (and below) automatically, with no import needed at all, which is how a growing test suite shares common setup without copy-pasting it into every file.
import pytest


class Wallet:
    def __init__(self, owner):
        self.owner = owner
        self.balance = 0

    def deposit(self, amount):
        self.balance += amount


@pytest.fixture
def funded_wallet():          # a plain function, marked as a fixture by the decorator alone
    wallet = Wallet(owner="ada")
    wallet.deposit(100)
    return wallet              # function-scoped by default: a brand-new wallet for every test that asks for it


def test_withdraw_reduces_balance(funded_wallet):   # naming the fixture as a parameter is the whole injection
    funded_wallet.balance -= 30

    assert funded_wallet.balance == 70


def test_a_second_test_gets_its_own_wallet(funded_wallet):
    assert funded_wallet.balance == 100   # unaffected by the withdrawal in the test above