Pytest & CI

Test Layout & Import Modes

ImportError while loading conftest, import file mismatch, ModuleNotFoundError: No module named 'myapp', a test that passes locally and fails in CI because a different copy of the package was imported: all four are the same problem wearing different hats. pytest's import behaviour is a small set of rules interacting with the shape of the repository, and once the rules are explicit the errors stop being mysterious.

Prerequisites

  • pytest >= 8.0; importmode=importlib has existed since 6.0 but became reliably usable for most layouts in 8.
  • A packaging setup that can install the project — pyproject.toml with any modern backend.
  • Python 3.9+, and an understanding of what sys.path ordering means for imports.
  • The configuration file hierarchy covered in pyproject.toml vs pytest.ini.

Core concept: two decisions, four common outcomes

Everything here follows from two independent choices: where the package sits relative to the repository root, and which import mode pytest uses.

Layout. A flat layout puts myapp/ at the repository root next to tests/. A src layout puts it at src/myapp/. The difference is whether the repository root — which pytest and Python both tend to put on sys.path — contains an importable copy of the package.

Import mode. Under prepend, pytest inserts the test file's rootdir-relative first non-package parent into sys.path[0] and imports the module under a name derived from its path. Under importlib, pytest imports the file directly through the import system without touching sys.path at all.

Layout and import mode as independent choices A two-by-two grid. Flat layout with prepend mode is the default and shadows the installed package while colliding on duplicate basenames. Flat layout with importlib removes the collisions but still shadows. Src layout with prepend avoids shadowing but still collides. Src layout with importlib avoids both problems. Two choices, and only one combination has neither problem importmode = prepend importmode = importlib flat layout src layout the default ✗ shadows the installed copy ✗ basename collisions half fixed ✗ still shadows ✓ no collisions half fixed ✓ tests the installed copy ✗ basename collisions recommended ✓ tests the installed copy ✓ no __init__.py needed
Both problems are real and independent, which is why fixing one and declaring victory leaves half the failures in place.

Step-by-step implementation

1. Move to a src layout

Plain text
repository/
├── pyproject.toml
├── src/
│   └── myapp/
│       ├── __init__.py
│       └── billing.py
└── tests/
    ├── conftest.py
    ├── billing/
    │   └── test_invoice.py
    └── api/
        └── test_invoice.py      ← same basename, different directory

With src/, the repository root contains no importable myapp, so import myapp can only resolve through the installed distribution. That single property removes an entire class of "works locally, fails in CI" failure, because locally and in CI now import the same thing.

2. Install the package rather than relying on path insertion

Bash
pip install -e .          # editable install: src/myapp becomes importable everywhere

An editable install puts the package on sys.path through the mechanism packaging provides, rather than through pytest's path manipulation. Tests then import myapp exactly the way a user would.

3. Declare the configuration explicitly

TOML
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
# importlib: no sys.path insertion, no module-name collisions, no __init__.py.
addopts = "--import-mode=importlib"

testpaths stops collection depending on the directory the command was run from. The import mode set in configuration rather than on the command line means every invocation — local, CI, an IDE's test runner — behaves the same.

4. Delete the __init__.py files from the test tree

Under importlib they are unnecessary, and leaving them creates a package whose name can collide with a real one. Under prepend they are mandatory wherever two test files share a basename, which is why suites that stay on prepend end up with them everywhere.

5. Verify from more than one directory

Bash
pytest -q                       # from the repository root
(cd tests/billing && pytest -q) # from a subdirectory
python -c "import myapp; print(myapp.__file__)"
Plain text
/home/dev/project/.venv/lib/python3.12/site-packages/myapp/__init__.py

The last command is the check that matters. If it prints a path inside the working tree rather than inside the environment, something is shadowing the installed package and the suite is not testing what will ship.

Verification

Two properties are worth asserting continuously rather than checking once.

Python
# tests/test_packaging.py
import pathlib
import sysconfig

import myapp


def test_imports_the_installed_distribution():
    """Guards against a path insertion shadowing the installed package."""
    site_packages = pathlib.Path(sysconfig.get_paths()["purelib"]).resolve()
    module_path = pathlib.Path(myapp.__file__).resolve()
    assert site_packages in module_path.parents, f"imported {module_path}"
Bash
# Collection must be identical from anywhere in the tree.
pytest --collect-only -q | md5sum
(cd tests && pytest --collect-only -q | md5sum)

Matching checksums mean testpaths and the import mode are doing their job. Differing ones mean collection depends on the working directory, and somebody's IDE will eventually run a different set of tests from CI.

Troubleshooting

SymptomRoot causeFix
import file mismatchTwo test files share a basename in non-package directories--import-mode=importlib, or add __init__.py
ModuleNotFoundError: myappPackage never installed; relying on rootdir insertionpip install -e .
Local pass, CI fail on a changed moduleFlat layout shadowing the installed copyMove to a src layout
ImportError while loading conftestconftest.py imports something only on the local pathImport through the installed package
IDE runs a different test setCollection rooted at a different directorySet testpaths in configuration
attempted relative import with no known parent packageTest file imported as a top-level moduleUse absolute imports in tests

What prepend mode actually does

Understanding the default explains almost every mismatch error. For each test file, pytest walks upward from the file's directory for as long as it finds __init__.py, and stops at the first directory that has none. That directory is the basedir. pytest inserts the basedir at sys.path[0] and imports the file under the dotted name formed from its path relative to the basedir.

So tests/billing/test_invoice.py with no __init__.py anywhere becomes module test_invoice, with tests/billing on the path. And tests/api/test_invoice.py becomes module test_invoice as well — the same name, already in sys.modules, pointing at a different file. pytest detects the mismatch and raises rather than silently running the wrong file, which is the error everyone has seen.

Adding __init__.py to tests/, tests/billing/ and tests/api/ changes the basedir to the repository root and the module names to tests.billing.test_invoice and tests.api.test_invoice, which are distinct. That works, and it is why the convention exists. The cost is that the test tree is now a package whose name is tests, which collides with any other installed distribution named tests and which must be excluded from the built wheel.

importlib mode sidesteps the whole mechanism: the file is imported by location, given a unique name, and nothing is added to sys.path. The historic reason not to use it was that test modules could not import each other by name; since pytest 8 the consider_namespace_packages option and improved name resolution make that rare enough that importlib is the better default for a new project.

How prepend mode derives a module name, and where it collides Two test files with the same basename in sibling directories both resolve to the module name test_invoice under prepend mode, colliding in sys.modules. Adding init files moves the basedir to the repository root so the names become distinct dotted paths, while importlib mode gives each file a unique name without touching sys.path. The same basename, two different resolutions tests/billing/test_invoice.py tests/api/test_invoice.py no __init__.py anywhere prepend both → "test_invoice" sys.modules collision import file mismatch prepend + __init__.py tests.billing.test_invoice tests.api.test_invoice works; "tests" is now a package importlib imported by location unique names, no sys.path edit no __init__.py needed
All three columns can be made to work. Only the right-hand one requires no packaging decisions about the test tree itself.

Where conftest.py is found, and where it is not

conftest.py follows its own rules, separate from the import mode, and they explain a second family of errors.

pytest collects conftest.py files from the rootdir down to each collected test file's directory, and applies them in that order: the root one first, the nearest one last. A fixture defined in a nearer file overrides one of the same name defined further up. Crucially, a conftest.py in a sibling directory is never consulted — tests/api/conftest.py has no effect on tests/billing/, however much it looks like shared configuration.

Which conftest files apply to a given test A directory tree. For a test in tests slash billing slash test invoice, the root conftest and the tests slash billing conftest both apply in that order, while the sibling tests slash api conftest does not apply at all. Fixtures in the nearer file override same-named fixtures in the outer one. Applied from the root down, never sideways tests/conftest.py — applies tests/billing/conftest.py — applies tests/billing/test_invoice.py tests/api/conftest.py — ignored here A fixture needed by two sibling directories belongs in their common parent, not duplicated in both.
Every "fixture not found" that survives an import-mode fix is this rule: the fixture is defined in a directory that is not an ancestor of the test requesting it.

One further rule catches people out. conftest.py files are imported before collection of the directory they govern, and they are always rewritten for assertions, but they are not affected by --import-mode in the same way test modules are — pytest imports them by path under a unique name regardless. That is why a conftest.py can safely exist in every directory without __init__.py, even under prepend, while two test_invoice.py files cannot.

Organising the tree once imports are settled

With imports out of the way, the layout question becomes purely about navigation, and two conventions do most of the work.

Mirror the package. tests/billing/test_invoice.py for src/myapp/billing/invoice.py means a reader can find a module's tests without searching, and a reviewer can see at a glance that a changed module has no test changes. It also makes coverage gaps visible as missing directories.

Separate by cost, not by philosophy. A top-level split between fast tests and those needing a database or a network is worth having, because it is the split CI uses. Splitting by "unit" and "integration" as categories tends to produce arguments about which a given test is; splitting by "needs Docker" and "does not" produces none, because it is a fact about the test rather than an opinion.

Plain text
tests/
├── conftest.py                 # shared, cheap fixtures only
├── unit/                       # no I/O; runs on every push
│   └── billing/test_invoice.py
└── integration/                # needs the container stack
    ├── conftest.py             # database and service fixtures
    └── billing/test_invoice.py

Placing the expensive fixtures in tests/integration/conftest.py rather than the root one matters more than it looks: a session-scoped container fixture defined at the root is collected for every run, and an accidental autouse or an import at module level will start Docker even for pytest tests/unit. Keeping it in the subtree that needs it means the fast suite never touches it, a property covered further in managing conftest hierarchies.

Monorepos and multiple distributions

A repository containing several installable packages multiplies every decision above, and the failure mode is subtle: one package's tests silently importing another package's working copy instead of its installed version.

Plain text
repository/
├── pyproject.toml              # workspace root: dev tooling only
├── packages/
│   ├── billing/
│   │   ├── pyproject.toml
│   │   ├── src/billing/
│   │   └── tests/
│   └── notifications/
│       ├── pyproject.toml
│       ├── src/notifications/
│       └── tests/
└── pytest.ini                  # rootdir anchor for whole-repo runs

Each package keeps its own src layout and its own tests, and every package is installed editable into the shared environment. A whole-repository run then collects every tests/ directory, and each test imports through the installed distributions rather than through path insertion — which is what makes a change to billing correctly break notifications' tests when it should.

Two configuration details keep this working. The root configuration file sets testpaths = ["packages"] so that running pytest from the root behaves the same as running it from a package. And each package's pyproject.toml declares its dependency on the others by name, so the environment resolves the graph rather than relying on everything happening to be on the path.

The temptation to avoid is a single conftest.py at the repository root providing fixtures for every package. It creates a dependency from each package's tests to the root of the monorepo, which means a package can no longer be tested in isolation — exactly the property a monorepo is supposed to preserve. Shared fixtures belong in a small installed package of their own, imported explicitly by whoever needs them, as described in sharing fixtures without conftest.py.

Testing the installed artefact, not the working copy

The strongest argument for the whole arrangement is that it makes one specific test possible: running the suite against the built wheel rather than the source tree.

Bash
python -m build                             # produces dist/myapp-1.4.0-py3-none-any.whl
python -m venv /tmp/verify && /tmp/verify/bin/pip install dist/*.whl pytest
cd /tmp && /tmp/verify/bin/pytest --import-mode=importlib /path/to/repo/tests

Running from /tmp guarantees the working tree is nowhere near sys.path, so the only importable myapp is the one inside the wheel. This catches the packaging mistakes that no amount of source-tree testing can: a subpackage missing from the wheel because it had no __init__.py, a data file not declared in the package data, a module that imported successfully only because a sibling directory happened to be adjacent.

It is worth running on every release and cheap enough to run nightly. Teams that ship libraries tend to add it after the first bug report that says "it works when I clone the repository but not when I pip install it", which is always a packaging problem and never a code one.

Migrating an existing suite

Changing layout and import mode in a live repository is a mechanical change with one sharp edge, so the order matters.

Move to the src layout first, in its own change, with pip install -e . added to the contributing instructions and to every CI job. This is the step most likely to break something — a script that did from myapp import x relying on the working directory, a tool configured with a relative path — and isolating it makes the breakage attributable.

Switch the import mode second. Add --import-mode=importlib, run the suite, and expect failures only where test modules import each other by bare module name (from test_helpers import make_order). Those should become imports from a proper helper package under tests/ that is installed or path-configured deliberately, which is a better arrangement anyway.

Delete the __init__.py files last, once the suite is green under the new mode, so that reverting the mode change remains possible up to that point. Doing all three at once produces a change nobody can bisect, and these failures are exactly the kind where bisecting is the fastest route to the cause. The specific errors and their fixes are catalogued in fixing import file mismatch errors in pytest.

A checklist that prevents recurrence

Import problems come back whenever a repository changes shape, so the durable fix is a handful of assertions about the project rather than a one-off cleanup.

The package is installed, not merely present: pip install -e . in the contributing instructions and in every CI job, with the packaging test above guarding it. The configuration is explicit: testpaths and --import-mode live in pyproject.toml, never in a shell alias or an IDE setting where only one person has them. The test tree is not a distribution: no __init__.py, excluded from the wheel, and containing nothing another package imports. And collection is location-independent, verified by the two-directory checksum comparison.

Four properties, each checkable in seconds, and between them they cover every failure in the troubleshooting table above. Writing them into a short note beside the configuration is worth more than fixing the errors individually, because the next person to add a package or move a directory reads the note instead of rediscovering the rules from the error messages.

Frequently Asked Questions

What actually causes 'import file mismatch' in pytest? Two test files with the same basename in directories that are not packages. Under the default prepend import mode pytest derives a module name from the basename alone, so the second file collides with the first in sys.modules. Adding __init__.py files makes the names unique, and importmode=importlib removes the collision entirely.

Should tests live inside the package or beside it? Beside it, in a top-level tests directory, when the package uses a src layout. Tests inside the package ship to users, get imported by anything that imports the package, and make it impossible to test the installed distribution rather than the working copy. Ship tests inside the package only when downstream consumers are meant to run them.

Do test directories still need init.py files? Not with importmode=importlib, which is the reason to switch. Under the default prepend mode they are needed wherever two test files could share a basename, which in a suite of any size is everywhere. Choosing importlib and deleting them is usually the simpler answer.

Why does pytest import a different copy of my package than I expect? Because rootdir insertion put the working directory on sys.path ahead of site-packages. With a flat layout the local directory shadows the installed distribution, so you test the working copy even when you meant to test the wheel. A src layout makes this impossible, which is its main argument.

What is rootdir used for, exactly? It anchors relative paths in configuration, cache locations and node identifiers. It does not by itself control imports. Import behaviour comes from the import mode and from which directories pytest inserts into sys.path, which are related to rootdir but not the same thing.

← Back to Advanced Pytest Architecture & Configuration