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— barepytestcollects onlytests/, so it never wanders into.venvor anode_modulesfull of Python samples.addopts— flags applied to every run.-raprints the reason for every non-passing outcome.--strict-markers— an unregistered mark becomes an error instead of a warning. This is what makes themarkerslist 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_*.pyor*_test.py - functions starting with
test_ - classes starting with
Testthat 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.