--import-mode=importlib is the import mode pytest's own documentation recommends for new projects, and the one that makes import file mismatch impossible. Switching an existing suite to it is a short, mechanical change, with one genuine piece of work — relocating any helpers that test files import from each other — and one verification step that turns the switch from a hope into a fact.
The payoff is a test tree that is no longer a Python package, needs no marker files, cannot collide with other distributions' module names, and behaves identically whichever directory pytest is launched from. For most suites the whole change fits in an afternoon, and the cross-test import cleanup it forces is an improvement independent of the import mode.
Prerequisites
pytest >= 8.0, whereimportlibmode handles namespace packages and rootdir-relative names reliably.- The package under test installed with
pip install -e ., so it is importable without path manipulation — see test layout and import modes. - A green suite to start from, so any failure after the switch is attributable to the switch.
Solution
Record the baseline, switch the mode, fix what breaks, then remove the marker files.
# 1. Baseline: exactly which tests are collected today.
pytest --collect-only -q > /tmp/before.txt
# 2. The switch, in configuration so IDEs and CI inherit it.
[tool.pytest.ini_options]
addopts = "--import-mode=importlib"
testpaths = ["tests"]
# 3. BEFORE: a test importing a sibling by bare name — breaks under importlib.
from test_helpers import make_order # tests/ is no longer on sys.path
# AFTER: helpers live in a real, importable module.
from tests_support.orders import make_order # installed, or found via rootdir
# 4. Once green, remove the markers and compare collection.
find tests -name "__init__.py" -delete
pytest --collect-only -q > /tmp/after.txt
diff <(sort /tmp/before.txt) <(sort /tmp/after.txt) && echo "collection unchanged"
An empty diff is the verification. The same tests, collected under the same ids, with none of the marker files and none of the collision risk.
Why this works
Under prepend, pytest makes test files importable by manipulating sys.path and deriving dotted names from the directory structure, which is why the marker files matter and why same-named files collide. Under importlib, pytest uses the import system's file-location machinery to load each file directly and assigns it a name derived from its path relative to the rootdir, which is unique by construction. Nothing is inserted into sys.path, so nothing in the test tree shadows anything else, and the marker files have no role left to play.
The one behavioural difference users notice is the one that breaks cross-test imports. Because the tests directory is no longer on sys.path, import test_helpers from a sibling test file has nowhere to resolve. That is not a limitation to work around; it is a sign the helper was in the wrong place, since a module that other modules import is not a test file.
Edge cases and failure modes
- Helpers imported by bare name. The one change that genuinely breaks. Move them into a test-support module, or expose them as fixtures from
conftest.py. - Doctests in the test tree.
--doctest-modulesimports modules the same way; doctests in test helpers need the same relocation. - Plugins that assume
prepend. A small number of older plugins compute module names themselves. Pin and upgrade them, or report the incompatibility; most have been updated. rootdirchanges. Module names underimportlibare derived relative to rootdir, so moving the configuration file changes node ids. Keep the configuration at the repository root.- Partial rollout. Using
importliblocally andprependin CI, or the reverse, produces failures that appear in one place only. Put the flag inaddopts, not in a CI script.
Where the shared helpers should live
The helper relocation is the part that needs a decision, and there are two good homes depending on what the helper is.
Behaviour that tests call — builders, assertion helpers, small utilities — belongs in a regular Python package that is importable by name. The lightest version is a tests_support/ directory next to tests/, declared as a package in pyproject.toml under an optional testing extra and installed with the editable install. Tests then import from it exactly as they import from the application.
Setup that tests request — objects built per test, resources with teardown — belongs in a fixture in conftest.py, where pytest's dependency injection makes it available without any import at all. Converting a helper function that returned a configured client into a fixture is usually a two-line change and removes the import entirely.
repository/
├── src/myapp/ # the application
├── tests_support/ # importable helpers: builders, assertions
│ ├── __init__.py
│ └── orders.py
└── tests/ # no __init__.py anywhere
├── conftest.py # fixtures: setup with teardown
└── billing/test_invoice.py
The split mirrors a distinction that is worth having regardless of import mode: helpers are code, fixtures are lifecycle. Mixing them in test files is what made the bare-name imports necessary in the first place.
In practice the relocation is smaller than it sounds. Running the suite once under the new mode lists every broken import as a collection error, each naming the file and the missing module. Most suites have a handful — a shared helpers.py, a factories.py, occasionally a module of constants — and moving them is a matter of creating the support package, moving the files, and updating the import lines, which a single search-and-replace usually handles. The collection errors themselves are the checklist, and the job is done when the list is empty. Nothing needs to be found by reading code or guessing at which modules import which.
How node ids change, and why that matters
The switch is invisible to most tests but not to everything that reads test identifiers. Under prepend, a node id reflects the path from the rootdir as before, and that does not change. What can change is the module name pytest assigns internally, which appears in a few places people rely on without realising.
Tooling that records test history by module name — some flake trackers, some coverage-per-test plugins, a handful of custom report hooks — may see every test as new after the switch, because tests.billing.test_invoice under prepend with markers becomes a path-derived name under importlib. The node ids that pytest prints and that JUnit XML records are path-based and stay stable, so dashboards keyed on them are unaffected. It is the rarer, module-name-keyed tooling that needs a one-time acknowledgement that history restarts.
Pickled objects and cached artefacts are the other place module names surface. A test cache that stored objects referencing test-module classes by qualified name will fail to unpickle after the switch. Clearing .pytest_cache and any custom caches once, as part of the change, avoids a confusing failure on the first run afterwards.
Neither issue is a reason to avoid the switch, but both are worth mentioning in the change description, so that whoever notices a flake tracker reporting a hundred "new" tests the next morning knows why and does not spend an hour on it.
A clean way to handle all of this is to make the switch its own pull request, separate from any test changes, with the before-and-after collection diff attached. Reviewers can then see at a glance that nothing about which tests run has changed, and the only behavioural differences are the cross-test imports that the same change relocated. That separation also makes the switch trivially revertible if an incompatible plugin turns up late, which in practice is the main risk and a small one.
Frequently Asked Questions
Where do shared test helpers go under importlib mode?
Into a module that is importable by name through normal means — a small installed test-support package, or a helpers module exposed through fixtures in conftest.py. Under importlib the tests directory is not on sys.path, so one test file importing another by bare name no longer resolves.
Does importlib mode change how conftest.py files are found?
No. conftest.py discovery follows the directory hierarchy exactly as before, and conftest files are imported by path under unique names in every mode. Fixtures defined in them remain available to tests beneath them.
Is importlib mode slower?
Not measurably. It skips the sys.path manipulation prepend mode performs and imports each file directly. Collection time is dominated by what the modules do at import, not by how pytest locates them.
Related
- Test Layout & Import Modes — the layout decisions that make this switch straightforward.
- Fixing import file mismatch Errors in pytest — the error this mode eliminates.
- Sharing Fixtures Without conftest.py — packaging fixtures for reuse across repositories.
- pyproject.toml vs pytest.ini — keeping the setting where every runner reads it.
← Back to Test Layout & Import Modes