Skip to main content
Pytest beginner Lesson 5 of 10

Test Layout and Pytest Configuration

Fix the ModuleNotFoundError every new pytest project hits, understand rootdir and conftest, and put your defaults in pyproject.toml instead of your shell history.

The first error in a new pytest project is almost never about testing. It is ModuleNotFoundError, and it comes from how pytest builds sys.path.

The error everybody hits

shop/
├── shop/
│   ├── __init__.py
│   └── orders.py
└── tests/
    └── test_orders.py
# tests/test_orders.py
from shop.orders import total


def test_total():
    assert total([("A1", 2, 4.50)]) == 9.00
$ pytest
========================= test session starts =========================
rootdir: /home/you/shop
collected 0 items / 1 error

=============================== ERRORS ================================
_______________ ERROR collecting tests/test_orders.py _________________
ImportError while importing test module '/home/you/shop/tests/test_orders.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
tests/test_orders.py:1: in <module>
    from shop.orders import total
E   ModuleNotFoundError: No module named 'shop'
==================== 1 error in 0.05s =================================

python -c "from shop.orders import total" works from the same directory. pytest fails because it inserts tests/ into sys.path — the directory of the test file — not the project root, and shop/ is not there.

The proper fix: install the project

# pyproject.toml
[project]
name = "shop"
version = "0.1.0"

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
pip install -e .
Successfully installed shop-0.1.0
$ pytest -q
.                                                                 [100%]
1 passed in 0.01s

An editable install puts your package on sys.path for real. Your tests now import it the same way your users will, which is the point — a test that only passes because of a path trick is not testing the shipped package.

The quick fix: pythonpath

[tool.pytest.ini_options]
pythonpath = ["."]
$ pytest -q
.                                                                 [100%]
1 passed in 0.01s

Fine for a script directory or a coding exercise. For anything you package, install it.

rootdir

Every run prints it:

rootdir: /home/you/shop
configfile: pyproject.toml
testpaths: tests

pytest walks up from the arguments looking for pytest.ini, pyproject.toml (with a [tool.pytest.ini_options] table), tox.ini, or setup.cfg. The directory holding the first one it finds becomes rootdir. That anchors relative paths in the config, the node IDs in the output, and where .pytest_cache lands.

When a config setting “does not work”, read this header first. A rootdir one level higher than you expected means pytest found a different config file — usually a stray setup.cfg in a parent directory.

Configuration in one place

# pyproject.toml
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
addopts = "-ra --strict-markers --strict-config"
markers = [
    "slow: takes more than a second",
    "integration: needs a live service",
]
xfail_strict = true
filterwarnings = ["error::DeprecationWarning"]
$ pytest
========================= test session starts =========================
platform linux -- Python 3.11.9, pytest-8.3.4, pluggy-1.5.0
rootdir: /home/you/shop
configfile: pyproject.toml
testpaths: tests
collected 24 items

tests/test_orders.py ................                           [ 66%]
tests/test_shipping.py ........                                 [100%]

========================== 24 passed in 0.42s =========================

Line by line, these are the settings worth having on day one:

  • testpaths — bare pytest collects only tests/, so it never wanders into .venv or a node_modules full of Python samples.
  • addopts — flags applied to every run. -ra prints the reason for every non-passing outcome.
  • --strict-markers — an unregistered mark becomes an error instead of a warning. This is what makes the markers list actually protect you from typos.
  • --strict-config — a typo in this config file becomes an error too.
  • filterwarnings — turns a chosen warning class into a failure, so deprecations get fixed while they are still warnings.

pytest.ini is equivalent if you prefer a separate file, and it wins when both exist. The important part is that it is committed, so CI and every developer run the same flags.

Where conftest.py goes

conftest.py is loaded automatically for every test at or below its directory — no import needed. Its position is the whole design:

shop/
├── pyproject.toml
├── conftest.py            ← fixtures for everything
└── tests/
    ├── conftest.py        ← fixtures for all tests
    ├── unit/
    │   └── test_total.py
    └── e2e/
        ├── conftest.py    ← browser fixtures, only for e2e
        └── test_checkout.py
# tests/conftest.py
import pytest


@pytest.fixture
def sample_order():
    return {"sku": "A1", "qty": 2, "price": 4.50}
# tests/e2e/conftest.py
import pytest


@pytest.fixture
def sample_order():
    """Same name, but the e2e suite needs a real persisted order."""
    order = create_order_in_db(sku="A1", qty=2)
    yield order
    delete_order(order["id"])
$ pytest tests -q
....                                                              [100%]
4 passed in 2.11s

Both suites request sample_order and each gets the nearest definition. Overriding by location is why fixture names in conftest.py can stay short and generic.

To see what is visible from a given test:

$ pytest tests/e2e --fixtures | grep -A2 sample_order
sample_order -- conftest.py:5
    Same name, but the e2e suite needs a real persisted order.

Duplicate basenames

The one layout rule that bites:

tests/
├── unit/
│   └── test_api.py
└── e2e/
    └── test_api.py
$ pytest
=============================== ERRORS ================================
_________________ ERROR collecting tests/e2e/test_api.py ______________
import file mismatch:
imported module 'test_api' has this __file__ attribute:
  /home/you/shop/tests/unit/test_api.py
which is not the same as the test file we want to collect:
  /home/you/shop/tests/e2e/test_api.py
HINT: remove __pycache__ / .pyc files and/or use a unique basename for your test file modules
==================== 1 error in 0.04s =================================

Two modules cannot both be test_api. Three fixes, in order of preference:

[tool.pytest.ini_options]
addopts = "--import-mode=importlib"

…which drops the requirement entirely; or add __init__.py to both directories so they become packages with distinct dotted names; or just rename one file to test_api_endpoints.py.

Naming rules

pytest collects, by default:

  • files matching test_*.py or *_test.py
  • functions starting with test_
  • classes starting with Test that have no __init__
class TestOrders:
    def __init__(self):          # this is the mistake
        self.orders = []

    def test_empty(self):
        assert self.orders == []
$ pytest -q
================================ warnings summary =================================
tests/test_orders.py:1
  cannot collect test class 'TestOrders' because it has a __init__ constructor

no tests ran in 0.01s

A warning, not an error, and zero tests ran. Use a fixture instead of a constructor. You can widen the naming rules if a legacy suite needs it:

[tool.pytest.ini_options]
python_files = ["test_*.py", "check_*.py"]
python_functions = ["test_*", "check_*"]

Practice

1. Create a src-layout project and run pytest before installing it.
$ pytest -q
E   ModuleNotFoundError: No module named 'shop'
1 error in 0.05s

$ pip install -e . && pytest -q
.                                                                 [100%]
1 passed in 0.01s

A src layout makes the failure unavoidable, which is its point: you cannot accidentally test the source tree instead of the installed package.

2. Add --strict-markers and use an unregistered mark.
$ pytest -q
=============================== ERRORS ================================
E   'flaky' not found in `markers` configuration option
1 error in 0.03s

The warning became a collection error. Nothing runs until the mark is registered or the typo is fixed.

3. Put a fixture in the root conftest.py and use it from two subdirectories.
$ pytest -q
....                                                              [100%]
4 passed in 0.03s

No imports in either test file. conftest.py is the only Python file pytest loads for you, which is exactly why fixtures live there and helper functions usually should not.

4. Run pytest --collect-only on a file whose functions do not start with test_.
$ pytest --collect-only -q

no tests ran in 0.01s

Silence, not an error. Collection problems almost always look like this — a run that reports success because it ran nothing. Check the collected count before trusting a green suite.

Next: replacing the parts of your system you cannot call in a test — clocks, HTTP, and environment.

Frequently Asked Questions

Why does pytest raise ModuleNotFoundError when my code runs fine?
pytest inserts the test file's rootdir-relative directory into sys.path, not your project root, so an import of your package fails if the package is not installed and not on the path. Installing the project with pip install -e . fixes it properly; the pythonpath ini setting is the quick alternative.
What is rootdir and why does it matter?
rootdir is the directory pytest treats as the project root, chosen from the config file it finds. It anchors relative paths in config, node IDs in output, and cache location. It is printed in the header of every run, so check it first when configuration seems ignored.
Where should pytest configuration live?
In pyproject.toml under [tool.pytest.ini_options] for a modern project, or pytest.ini if you prefer a dedicated file. pytest.ini wins if both exist. Keeping it in one committed file means every developer and CI runner uses the same flags.
Do I need __init__.py files in my test directories?
Not with the default import mode, as long as no two test files share a basename. If you have tests/unit/test_api.py and tests/e2e/test_api.py, add __init__.py to both directories or switch to importmode=importlib, otherwise the second file fails to import.