Skip to content
← All articles

pytest Project Layout: conftest, rootdir, Import Modes

Why a second file called test_utils.py breaks your suite, what rootdir actually decides, the three import modes and their real trade-offs, and the ini settings that replace sys.path hacking.

Here is a bug that will cost someone an afternoon, and it is not in anybody’s code.

A repo has tests/unit/test_utils.py. It has been green for a year. Someone adds tests/integration/test_utils.py. Now the suite fails — not the new file, the whole collection — with this:

ImportError: import file mismatch:
imported module 'test_utils' has this __file__ attribute:
  /repo/tests/unit/test_utils.py
which is not the same as the test file we want to collect:
  /repo/tests/integration/test_utils.py
HINT: remove __pycache__ / .pyc files and/or use a unique basename
for your test file modules

Nothing was renamed. Nothing was refactored. The first file was fine on its own and is fine now; the second file is fine on its own too. They are only broken together, which is why nobody caught it in review and why the git bisect points at a commit that looks innocent.

Understanding why takes about ten minutes and then you never hit it again.

What pytest does to import a test file

pytest does not import your test modules the way your application imports its own. It has to import a file it was handed by path, which is not a thing Python’s import system does natively. In the default import mode, prepend, the algorithm is:

  1. Start at the test file and walk upwards as long as each directory contains an __init__.py. The first directory that does not is called the basedir.
  2. Insert that basedir at the front of sys.path.
  3. Import the module under the dotted name implied by the remaining path.

For tests/unit/test_utils.py with no __init__.py anywhere, the basedir is tests/unit/ and the module name is test_utils — the bare basename. For tests/integration/test_utils.py, the basedir is tests/integration/ and the module name is, once again, test_utils.

Two files, one module name. The second import finds test_utils already in sys.modules, notices its __file__ points somewhere else, and refuses — correctly, because silently returning the first file’s tests under the second file’s name would be far worse.

💡The HINT in that error suggests deleting __pycache__. Under what circumstances is that actually the fix, and why does the hint mislead so many people? click to reveal

Almost never, and it misleads because it is listed first.

Stale .pyc files cause a genuinely different failure — one where a __pycache__ directory survives after its .py file was moved or deleted, so pytest imports a module whose __file__ points at a path that no longer exists. That does happen, usually right after someone reorganises directories, and find . -name __pycache__ -exec rm -rf {} + really does fix it.

But the duplicate-basename case is not stale state. It is two live files that genuinely want the same module name, and no amount of cache clearing changes that. People try the first hint, watch it fail, try it again with more force, and only then read the second half of the sentence.

The two real fixes are in the rest of the hint: rename one file so the basenames are unique, or add __init__.py to both test directories so the basedir moves up to tests/‘s parent and the module names become tests.unit.test_utils and tests.integration.test_utils — distinct, and unambiguous forever. The second is what you want in any repo big enough for the collision to have happened once, because it stops the class of bug rather than this instance of it.

The three import modes

--import-mode (or importmode in the ini file) takes three values, and they differ in exactly two respects: what they do to sys.path, and how they name the module.

prepend — the default. Inserts the basedir at position 0 of sys.path and imports by basename. The prepend is why your local tests/ directory can shadow an installed package of the same name, and why basenames must be unique unless your test directories are real packages.

append — identical, but the basedir goes at the end of sys.path. This is what you want when your test tree deliberately contains a module that shares a name with something installed and you want the installed one to win. Same uniqueness requirement.

importlib — imports the file directly through importlib, with no sys.path mutation at all. Basenames need not be unique, because the module is not being resolved by name in the first place. It is the cleanest mechanism of the three, and it comes with a real cost: test modules imported this way are not necessarily importable by each other, so a suite where tests/helpers.py is imported by tests/test_thing.py needs rework.

It is worth being precise about the status of importlib, because a wave of blog posts in the last few years told you it was about to become the default. It is not. pytest’s own documentation walked that plan back, in these words:

it has its own set of drawbacks so the default will remain prepend for the foreseeable future

So treat importlib as a deliberate choice for a specific problem — usually a monorepo with many independently-named test trees — and not as the modern default you are behind on.

rootdir is not what most people think it is

pytest prints rootdir: in its header, and people assume it is added to sys.path. It is not. rootdir does exactly two things: it is the base for constructing the nice relative paths in the output, and it is where pytest looked for configuration.

It is determined from the arguments you passed and the first ancestor directory containing a recognised config file — pytest.ini, a pyproject.toml with a [tool.pytest.ini_options] table, tox.ini with a [pytest] section, or setup.cfg. The practical consequence is that rootdir can move depending on what you invoke: pytest tests/unit and pytest from the repo root can pick different rootdirs in a repo with nested config files, and then a relative path in your config resolves against a different directory than you meant.

The defence is to have exactly one config file, at the top of the repo, and to set testpaths in it so that a bare pytest and a targeted pytest tests/unit agree about where the project starts.

The settings that replace sys.path surgery

Every repo eventually grows a conftest.py containing this:

import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

It works, it is invisible to anyone reading pyproject.toml, and it silently changes import resolution for the whole run. There has been a supported alternative since pytest 7:

[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]

pythonpath entries are resolved relative to rootdir and inserted at the front of sys.path for the session. Same effect, declared where a reader will look for it.

Two more worth knowing. consider_namespace_packages (default false) controls whether pytest treats directories without __init__.py as PEP 420 namespace packages when working out a module’s name — turn it on only if your project genuinely uses namespace packages, because it changes what module names your test files get. And --import-mode belongs in the ini file rather than in a CI command, so that a developer running pytest locally gets the same import semantics as the pipeline.

💡Your package lives at the repo root — mypackage/ next to tests/ — and everything passes locally. CI installs the wheel and runs the same suite, and a test fails with an ImportError for a data file that is definitely in the source tree. What happened, and what layout prevents it? click to reveal

Locally you were never testing the package. You were testing the source directory.

With a flat layout, the repo root ends up on sys.path — via prepend mode, via a sys.path hack in conftest.py, or simply because you ran pytest from that directory. import mypackage then resolves to ./mypackage/, the working tree, not to whatever pip install put in site-packages. Every file in your source tree is reachable whether or not it is listed in the wheel.

CI installs the built artefact, where the data file was never included because nothing in pyproject.toml said to include it. The wheel is broken and has been for weeks; the only reason nobody noticed is that the test suite was reading around it.

This is precisely why the packaging documentation and pytest’s own guidance recommend the src layout: put the package at src/mypackage/ and the repo root stops being importable as a package location. The only way to import mypackage is to have installed it — pip install -e . in development — and from that moment your tests exercise the same artefact your users get. Packaging mistakes become test failures on the day they are made instead of on release day.

The related habit: run pytest rather than python -m pytest when you want this property. python -m pytest prepends the current directory to sys.path, which quietly reintroduces exactly the shadowing you just removed.

The layout to just use

pyproject.toml          # one config file, at the root
src/mypackage/__init__.py
tests/__init__.py       # makes tests a package
tests/unit/__init__.py
tests/unit/test_utils.py
tests/integration/__init__.py
tests/integration/test_utils.py
conftest.py             # fixtures, no sys.path surgery
[tool.pytest.ini_options]
testpaths = ["tests"]

The __init__.py files cost nothing and permanently retire the duplicate-basename failure. The src layout makes packaging errors visible. The single config file pins rootdir. And with testpaths set, pytest with no arguments does the same thing everywhere — which is the actual goal, because a test suite whose behaviour depends on which directory you were standing in is a test suite nobody trusts.