Resource library

QA How-To

pytest conftest.py Explained with Examples

Pytest conftest.py explained with runnable fixtures, nested overrides, scope, teardown, hooks, autouse examples, verification commands, and troubleshooting.

20 min read | 3,248 words

TL;DR

A pytest conftest.py file is discovered for tests in its directory and descendant directories. Define shared fixtures and local hooks there, use nested conftest files for targeted overrides, and inspect resolution with --fixtures and --setup-show.

Key Takeaways

  • Place conftest.py at the lowest directory that needs its fixtures or hooks.
  • Request shared fixtures by name in tests; do not import conftest.py directly.
  • Use function scope for mutable test data and wider scopes only for safely shared resources.
  • Use yield fixtures for teardown and protect partial setup separately.
  • Override a fixture in a child conftest without changing sibling tests.
  • Inspect discovery with --fixtures, --setup-show, and --collect-only before changing code.

Pytest conftest.py explained with examples starts with one rule: put fixtures and local pytest hooks in a conftest.py when tests beneath that directory need them. Pytest discovers the file automatically, so tests request a fixture by name without importing it. A parent file reaches descendants; a child file can specialize behavior for one branch of the suite.

That discovery rule is easy to repeat and easy to misuse. A fixture placed too high can affect unrelated tests, a mutable session fixture can leak state, and an implicit autouse fixture can hide the reason a test passes. Build the small receipt suite below to see the actual lookup, setup, teardown, override, and diagnostic behavior.

The examples use Python's standard library plus pytest. Keep earlier files as you progress, and run commands from the project root containing app.py and pyproject.toml.

What You Will Build

  • A receipt calculator with prices stored as integer cents and tax expressed in basis points.
  • A shared tests/conftest.py that supplies test baskets without imports in test modules.
  • A temporary receipt fixture that yields a file and removes it during teardown.
  • A session-scoped immutable fixture and a directory-specific fixture override.
  • A --region command-line option, a narrowly placed autouse fixture, and commands that expose pytest's fixture graph.

The finished layout has a root app.py, a pyproject.toml, and a tests/ directory containing the root conftest and focused test files. You can paste each block into the named file as you reach its step. The expected values are calculated in the article so a passing run means more than an assertion copied without understanding.

Prerequisites

Use Python 3.12 and pytest 9.1.1 for the exact commands in this tutorial. PyPI lists pytest 9.1.1 as a published release, supports Python 3.12, and requires Python 3.10 or newer. If your team pins another installed pytest release, match that release in your environment and check its documentation before copying a version-specific setting. The APIs used here are documented in the current pytest fixture guide and fixture reference.

From a new, empty conftest-demo directory, create and activate a virtual environment. On macOS or Linux:

python3 --version
python3 -m venv .venv
. .venv/bin/activate
python -m pip install 'pytest==9.1.1'
python -m pytest --version

On Windows PowerShell, activate with .venv\Scripts\Activate.ps1 and use py -3.12 -m venv .venv if python does not resolve. The first version command should identify Python 3.12, and the last should identify pytest 9.1.1. Keep the environment out of version control. There is no database, browser, network service, or plugin prerequisite for the runnable example.

If you are new to discovery conventions, read the pytest tutorial for beginners first. The steps here concentrate on how conftest changes the available fixtures and hooks, rather than on basic assertion syntax.

Step 1: Pytest conftest.py Explained with a Minimal Test Layout

Create app.py in the project root. Use integer arithmetic so the example has deterministic totals on different machines:

# app.py
from pathlib import Path
import os


def total_cents(prices: list[int], tax_basis_points: int = 0) -> int:
    if any(price < 0 for price in prices):
        raise ValueError("prices must be nonnegative")
    if tax_basis_points < 0:
        raise ValueError("tax must be nonnegative")
    subtotal = sum(prices)
    tax_cents = (subtotal * tax_basis_points + 5_000) // 10_000
    return subtotal + tax_cents


def write_receipt(path: Path, amount_cents: int) -> None:
    path.write_text(f"TOTAL {amount_cents} cents\n", encoding="utf-8")


def receipt_mode() -> str:
    return os.environ.get("RECEIPT_MODE", "production")

Basis points mean one hundredth of one percent, so 750 basis points means 7.5%. The added 5,000 rounds a positive fraction of a cent to the nearest integer cent, with halves rounded up. For prices 1,299 and 250 cents, the subtotal is 1,549; 7.5% tax rounds to 116 cents; the total is 1,665 cents.

Add pyproject.toml to make collection predictable and register the test directory:

# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"

Create the empty directory with mkdir -p tests on macOS or Linux, or New-Item -ItemType Directory tests in PowerShell. This layout needs no __init__.py. Running from the project root makes app.py importable.

Verify: Run python -c "from app import total_cents; assert total_cents([1299, 250], 750) == 1665". A zero exit status checks the application before fixture setup enters the picture. Then run python -m pytest --collect-only -q; it should report no tests collected because none have been created yet. Pytest exits with code 5 when it collects no tests; that is expected here.

Step 2: Share Fixtures Through tests/conftest.py

Create tests/conftest.py with two named fixtures. Do not import this file in a test: pytest loads it as a local plugin during collection.

# tests/conftest.py
import pytest


@pytest.fixture
def basket_prices() -> list[int]:
    return [1299, 250]


@pytest.fixture
def tax_basis_points() -> int:
    return 750

Create tests/test_totals.py:

# tests/test_totals.py
from app import total_cents


def test_taxed_basket(basket_prices, tax_basis_points):
    assert total_cents(basket_prices, tax_basis_points) == 1665


def test_price_validation():
    try:
        total_cents([-1])
    except ValueError as error:
        assert str(error) == "prices must be nonnegative"
    else:
        raise AssertionError("negative price was accepted")

Pytest sees the names in test_taxed_basket's parameters and resolves them from the conftest visible to that test. The first fixture returns a new list for each requesting test because function is the default scope. The second test does not request either fixture, so pytest does not construct them for it. A conftest.py is about fixture availability, not about running all of its functions before every test.

A test module can contain fixtures too, but those remain local to that module. Put a fixture in conftest when multiple test files in the same subtree need the same setup. If the implementation becomes substantial, move ordinary helper logic into a regular Python module and keep the fixture itself thin. This division prevents a conftest from turning into an unstructured utility library.

Verify: Run python -m pytest tests/test_totals.py -q. Expect 2 passed. Then run python -m pytest --fixtures tests/test_totals.py -q and find basket_prices and tax_basis_points in the listing. If one is absent, check the exact filename conftest.py, the test's directory, and the fixture spelling.

Step 3: Yield a Temporary Receipt and Tear It Down

Append this fixture to tests/conftest.py. It composes the fixtures from Step 2 with pytest's built-in tmp_path fixture:

# Append to tests/conftest.py
from pathlib import Path
from app import total_cents, write_receipt


@pytest.fixture
def receipt_file(
    tmp_path: Path, basket_prices: list[int], tax_basis_points: int
):
    path = tmp_path / "receipt.txt"
    write_receipt(path, total_cents(basket_prices, tax_basis_points))
    yield path
    path.unlink(missing_ok=True)

Create tests/test_receipt_file.py:

# tests/test_receipt_file.py
def test_receipt_contains_total(receipt_file):
    assert receipt_file.read_text(encoding="utf-8") == "TOTAL 1665 cents\n"


def test_each_request_gets_a_file(receipt_file):
    assert receipt_file.name == "receipt.txt"
    assert receipt_file.is_file()

Code before yield is setup; the yielded path is the fixture value; code after yield is teardown. Pytest runs that teardown after each test using this function-scoped fixture, including when the assertion fails after setup succeeds. If setup raises before yield, the teardown portion of that fixture is not reached, so acquire multiple resources in separate fixtures or protect partially acquired resources with try/finally when necessary.

The built-in tmp_path gives each test a distinct directory. Both tests can use the same filename without sharing the same actual file. The explicit unlink demonstrates teardown, although pytest's temporary directory handling already isolates these files. In a real suite, replace this harmless deletion with cleanup of a resource your fixture actually owns, such as a local socket or a test record. Never use a shared production path to illustrate teardown.

Verify: Run python -m pytest tests/test_receipt_file.py -q; expect 2 passed. Run python -m pytest tests/test_receipt_file.py --setup-show -q to see basket_prices, tax_basis_points, tmp_path, and receipt_file setup and teardown around each test. The visible ordering follows dependencies, not the textual order in the test function signature.

Step 4: Choose Scope Based on Ownership

Append a session-scoped fixture to tests/conftest.py, then test it. A tuple is safe to share because a test cannot append to it or change its entries in place:

# Append to tests/conftest.py
@pytest.fixture(scope="session")
def supported_currencies() -> tuple[str, ...]:
    return ("USD", "EUR", "INR")
# tests/test_currencies.py
def test_usd_is_supported(supported_currencies):
    assert "USD" in supported_currencies


def test_currency_names_are_unique(supported_currencies):
    assert len(supported_currencies) == len(set(supported_currencies))

A session-scoped fixture is constructed once per pytest process for each applicable parameter value, not once for an entire CI matrix. The two tests above can safely read the same tuple. By contrast, the basket_prices list is function-scoped: a test that changes it cannot contaminate the next test's basket. If you made that list session-scoped to save a tiny allocation, append in one test could change another test's total.

Pytest also supports class, module, and package scopes. Choose the smallest lifetime that matches the resource: function for mutable test data, module for a resource intentionally shared by one file, package for a package-level resource, and session for stable process-wide setup. A wider scope does not make the fixture visible in more directories; placement controls visibility while scope controls cache lifetime. That distinction is crucial when diagnosing an apparently missing fixture.

Avoid a session-scoped fixture that directly requests a function-scoped fixture. Pytest raises ScopeMismatch because a long-lived object cannot safely depend on a shorter-lived one. Instead, give both fixtures compatible scopes or create the short-lived data from the session fixture in a function-scoped wrapper.

Verify: Run python -m pytest tests/test_currencies.py --setup-show -q. Expect 2 passed; the setup display should show supported_currencies once for the session. Run python -m pytest -q afterward to confirm the earlier receipt and basket tests still pass.

Step 5: Override a Fixture in One Child Directory

Create tests/discounts/conftest.py. The child fixture has the same name as the parent fixture and requests that name itself. Pytest resolves the request to the next fixture outward, so this version halves the parent tax rate for tests beneath tests/discounts/:

# tests/discounts/conftest.py
import pytest


@pytest.fixture
def tax_basis_points(tax_basis_points: int) -> int:
    return tax_basis_points // 2

Add a child test:

# tests/discounts/test_discount_tax.py
from app import total_cents


def test_discount_branch_uses_reduced_tax(basket_prices, tax_basis_points):
    assert tax_basis_points == 375
    assert total_cents(basket_prices, tax_basis_points) == 1607

The subtotal remains 1,549 cents. At 375 basis points, the tax is about 58.09 cents and rounds to 58, so the result is 1,607 cents. A sibling test outside discounts still receives 750 basis points and expects 1,665. This is a controlled override of one input, not a mutation of a shared global variable.

Fixture lookup starts from the requesting test and moves outward through its visible conftest files. A test in the root tests/ directory cannot search downward into tests/discounts/. Nested conftest files are therefore a useful boundary for API versus UI tests, locale variations, or a specific integration environment. Keep an override's name aligned with the parent when the child is intentionally replacing that contract; use a different name when it represents a different concept.

Verify: Run python -m pytest tests/discounts/test_discount_tax.py tests/test_totals.py -q; expect 3 passed. Run python -m pytest tests/discounts/test_discount_tax.py --fixtures -q and inspect the available tax_basis_points definitions. The child's result is proved by the 375 assertion, while the root test proves the parent was not changed.

Step 6: Add a Local Command-Line Option in conftest.py

A conftest can also register pytest hooks. Append this hook and fixture to the root tests/conftest.py to select a region at invocation time:

# Append to tests/conftest.py
def pytest_addoption(parser):
    parser.addoption(
        "--region",
        action="store",
        choices=("us", "eu"),
        default="us",
        help="region used by regional receipt tests",
    )


@pytest.fixture
def region_tax_basis_points(request) -> int:
    rates = {"us": 750, "eu": 2000}
    return rates[request.config.getoption("--region")]

Create a test that computes its expected total from the selected rate:

# tests/test_regional_total.py
from app import total_cents


def test_regional_total(basket_prices, region_tax_basis_points):
    expected = 1665 if region_tax_basis_points == 750 else 1859
    assert total_cents(basket_prices, region_tax_basis_points) == expected

With 2,000 basis points, tax on 1,549 cents rounds from 309.8 to 310 cents, so the EU total is 1,859. The option controls only tests that request region_tax_basis_points; it does not silently change the earlier tax_basis_points fixture or the child override. This keeps a CLI choice from altering unrelated tests.

pytest_addoption runs during pytest startup, before tests execute. Root conftest is an appropriate place for a suite-wide option, while plugin packages are better when multiple projects need the same hook. Use registered choices to reject typos immediately; --region=ue should fail at command-line parsing rather than produce an obscure assertion later.

Verify: Run python -m pytest tests/test_regional_total.py -q --region=us and then python -m pytest tests/test_regional_total.py -q --region=eu. Each should show 1 passed. Run python -m pytest --help and locate the option description. Finally, run python -m pytest -q to verify that the default choice leaves the entire suite green.

Step 7: Confine Autouse Setup to a Subtree

Create tests/isolated/conftest.py. An autouse fixture is requested implicitly by every test to which it is visible. Place it narrowly so it does not rewrite environment state for unrelated tests:

# tests/isolated/conftest.py
import pytest


@pytest.fixture(autouse=True)
def test_receipt_mode(monkeypatch):
    monkeypatch.setenv("RECEIPT_MODE", "test")
# tests/isolated/test_mode.py
from app import receipt_mode


def test_isolated_receipt_mode():
    assert receipt_mode() == "test"

The built-in monkeypatch fixture restores the previous environment after the test, whether that previous value was absent or supplied by the shell. The child conftest applies only to tests inside tests/isolated/, so the root and discounts tests do not acquire this autouse fixture. A test in that subtree could explicitly request test_receipt_mode, but it need not do so for the side effect to run.

Use autouse sparingly. It fits a true invariant such as disabling a network call for every test in a directory, but it can obscure dependencies when it performs database inserts, changes time, or changes authentication behind a test's back. An explicit fixture parameter communicates why one particular test needs a particular resource. If most tests do not need the side effect, do not put an autouse fixture in the root conftest.

Verify: Run python -m pytest tests/isolated/test_mode.py --setup-show -q; expect 1 passed and a setup entry for test_receipt_mode even though the test has no fixture argument. Then run python -m pytest -q for the whole suite. The environment restoration can be inspected by running a separate Python process after pytest; pytest cannot change its parent shell's environment.

Step 8: Pytest conftest.py Explained Through Discovery Commands

The same conftest.py can make a suite feel mysterious when a fixture name is mistyped or an unexpected override is selected. Use pytest's built-in inspection commands before adding print statements:

python -m pytest --collect-only -q
python -m pytest tests/discounts/test_discount_tax.py --fixtures -q
python -m pytest tests/discounts/test_discount_tax.py --setup-show -q
python -m pytest tests/test_receipt_file.py -vv

--collect-only shows which test files pytest found. --fixtures lists fixtures available for the selected path and where they are defined. --setup-show prints setup and teardown activity, which is particularly useful for a yield fixture or a suspected autouse side effect. -vv prints full test IDs when a failure report is too compressed. Use a narrow path when inspecting fixtures so unrelated plugins and tests do not dominate the output.

If you move the working directory or change testpaths, verify collection first. A conftest file outside the selected test path may not participate in the run as you expected. In larger repositories, collection boundaries and the chosen pytest root directory matter; use the reported rootdir and configfile values in normal pytest output to confirm that the intended configuration loaded.

You can also ask pytest for one fixture's help with python -m pytest --fixtures tests/test_totals.py -q and search the output for its name. Avoid importing tests.conftest as an application module. Pytest's special discovery mechanism and nested overrides are the public contract here; direct imports bypass that contract and can create duplicate module identities.

Verify: Run the four commands above. The final full-suite check is python -m pytest -q. Expect 9 passed: two tests each in test_totals.py, test_receipt_file.py, and test_currencies.py, plus one each in the discount, regional, and isolated test files.

Troubleshooting

Problem: fixture 'basket_prices' not found -> Put conftest.py in tests/ or an ancestor of the requesting test, check the filename and fixture name, then run --fixtures for that exact test path. A sibling or child directory's conftest cannot provide a fixture upward.

Problem: ScopeMismatch after changing a fixture to session scope -> Inspect its dependencies. A session fixture cannot request the function-scoped tmp_path or another function fixture; use tmp_path_factory for session-owned temporary data or return to function scope if each test needs isolation.

Problem: the child test gets 750 instead of 375 basis points -> Confirm that test_discount_tax.py lives under tests/discounts/ and the override is named tax_basis_points. Run --setup-show on that specific test. A typo in the child filename or a test placed one directory higher changes lookup.

Problem: teardown does not run after setup fails -> The code before yield did not complete. Move acquisitions into smaller fixtures or wrap partial setup in try/finally. The post-yield block handles successful setup followed by a failing test; it cannot clean an object never yielded.

Problem: --region is unrecognized -> Run pytest against the configured project from its root and confirm tests/conftest.py is loaded. Put an option used across the suite in a reliably loaded root conftest or a registered plugin; do not register it only in a deeply nested branch.

Problem: a test passes alone but fails with the suite -> Check for mutable data in a module or session fixture, hidden autouse behavior, or a file outside tmp_path. Run the suspect pair with --setup-show and compare fixture lifetimes. Shared state should have explicit ownership and cleanup.

Interview Questions and Answers

Q: What does conftest.py do in pytest? It supplies fixtures and local hooks to tests under its directory through pytest discovery. Tests request fixtures by name, without importing conftest. Directory placement establishes which tests can see a definition.

Q: How does a nested conftest override a parent fixture? A test searches its local scope outward. A same-named fixture in the child directory wins for that subtree; the parent remains available to other tests. A child override may request the parent fixture by the same name to extend its value.

Q: Does putting a fixture in conftest make it run automatically? No. It makes the fixture available. A test or another fixture must request it, unless the fixture is marked autouse=True or included through another supported use mechanism.

Q: What is the difference between fixture scope and fixture visibility? Scope controls how long pytest caches a constructed fixture value. Visibility follows the test's location relative to modules, conftest files, and plugins. A session fixture defined in a child directory is still unavailable to a parent test.

Q: Why use yield instead of return in a fixture? yield gives the test a value and reserves the code after it for teardown. return supplies a value with no post-test fixture body. For partial setup failure before yield, use smaller fixtures or try/finally to protect acquired resources.

Q: When is an autouse fixture appropriate? Use it for an invariant that truly applies to every test in its visible subtree, such as a controlled environment variable. Place it in the narrowest directory and inspect it with --setup-show. Prefer explicit requests for resources that only selected tests need.

For deeper practice, compare this design with pytest versus unittest, where setup lifecycle differs, and with top pytest interview questions.

Common Mistakes

  • Importing a conftest fixture as a normal Python symbol instead of requesting it in a test signature.
  • Putting a fixture in the repository root when only one small test subtree needs it.
  • Increasing scope to session for speed while returning a mutable list or client shared across tests.
  • Assuming a fixture executes merely because pytest discovered its definition.
  • Using an autouse fixture for hidden writes that make test order matter.
  • Assuming a nested conftest changes sibling tests or makes fixtures visible upward.
  • Deleting a shared path in teardown rather than only the resource that the fixture created.
  • Diagnosing a missing fixture by editing application logic before checking collection and fixture lookup.

Where To Go Next

Apply the same directory-boundary reasoning to a real API suite: build an API automation framework in Python shows a broader framework context. If browser tests are next, Playwright Python fixtures with pytest connects pytest fixtures to browser resources. When you add parallel workers, read Playwright Python parallel testing with pytest-xdist and reconsider shared files and session resources: each worker is a separate process.

For this sample, try one safe extension: add tests/zero_tax/conftest.py that overrides tax_basis_points with zero, then assert a total of 1,549 cents in a child test. Run the child and root tests together. If the root still expects 1,665, you have demonstrated the most useful property of nested conftest files: local behavior without rewriting the common fixture.

Conclusion

Pytest conftest.py explained through the running suite is a directory-level mechanism for fixture and hook discovery. Root fixtures supply ordinary defaults, child conftest files specialize one branch, fixture scope determines lifetime, and yield provides explicit teardown. The lookup and lifecycle are observable with --fixtures and --setup-show.

Keep each conftest close to the tests it serves. Make mutable data short-lived, keep overrides intentional, and rerun the focused path plus the full suite after changing shared fixtures. That gives you reusable setup without turning test outcomes into a function of hidden state.

Interview Questions and Answers

How does pytest discover fixtures defined in conftest.py?

Pytest loads conftest files along the test's directory ancestry during collection. A test requests a fixture by parameter name, and pytest resolves the closest visible definition before looking outward. No explicit test import is needed.

What happens when parent and child conftest files define the same fixture?

The child definition serves tests in that subtree. Tests outside it continue to receive the parent definition. The child can request the same fixture name in its own signature to build on the parent value.

How do you prevent shared fixture state from leaking between tests?

I keep mutable data at function scope and create a fresh value for each test. If a resource must have broader scope, I make its exposed interface immutable or explicitly manage ownership. I run tests together to detect contamination that isolated runs miss.

When do you use a yield fixture?

I use yield when setup creates something that must be released after the test, such as a file, connection, or account. The fixture yields the usable value, then performs cleanup afterward. For failure during acquisition, I use try/finally or split dependencies so cleanup is registered safely.

What causes ScopeMismatch in pytest fixtures?

A broader-scope fixture has requested a narrower-scope fixture, such as a session fixture depending on tmp_path. That lifetime relationship is invalid because the short-lived value cannot serve the long-lived fixture. I align scopes or use an appropriate broader-scope factory such as tmp_path_factory.

How do you decide whether a fixture belongs in conftest.py?

I put a fixture there when several tests in the same directory tree share it. A fixture used by one test module can stay in that module. Reusable implementation logic belongs in a normal helper module so the conftest remains easy to scan.

What is an autouse fixture and what risk does it introduce?

An autouse fixture runs for every test to which it is visible without appearing in the test signature. It works for a true directory-wide invariant, but hidden side effects can make failures hard to explain. I place it narrowly and inspect its execution with --setup-show.

How would you debug the wrong fixture being injected?

I select the failing test path and run --fixtures to inspect definitions, then --setup-show to see actual setup. I check the test's directory, nested conftest names, plugin fixtures, and spelling. A nearby override usually explains a value that differs from a root test's value.

Frequently Asked Questions

What is conftest.py in pytest?

It is a specially discovered Python module for fixtures and local pytest hooks. Tests in its directory or descendant directories can request its fixtures by name without importing the module.

Where should I put conftest.py?

Put it in the lowest test directory shared by the tests that need its definitions. Use a child conftest for a branch-specific override, and keep the root file limited to genuinely suite-wide behavior.

Can I have multiple conftest.py files?

Yes. Parent and child directories can each have one, and tests in the child can use visible fixtures from both. A child fixture with the same name overrides the parent for tests in that subtree.

Do I import fixtures from conftest.py?

No. Add the fixture name to the test function's parameters and let pytest inject it. Direct imports bypass the discovery and override behavior that conftest exists to provide.

Does conftest.py run before every test?

Pytest loads it during collection, but fixture functions run only when requested, unless they are autouse. Fixture scope then determines whether an instance is reused within a function, class, module, package, or session.

How do I debug a fixture not found error?

Run pytest --fixtures for the failing test path and confirm the name appears. Check the exact conftest filename, its location relative to the test, the active root directory, and whether a typo changed the fixture name.

How do I clean up after a conftest fixture?

Use a yield fixture and put cleanup after yield. If resource acquisition can fail before yield, protect that partial setup with try/finally or split resources into smaller fixtures.

What is the difference between conftest.py and a plugin?

A conftest provides local behavior within a directory tree. A plugin is better for reusable fixtures and hooks shared across multiple repositories or test suites.

Related Guides