pytest Cannot Import My GIS Modules: How to Fix It
Problem statement
The pipeline runs. The tests do not.
$ python src/pipeline.py
processed 412 files
$ pytest
ImportError while importing test module '/home/you/project/tests/test_transforms.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
tests/test_transforms.py:4: in <module>
from src.transforms import buffer_metres
E ModuleNotFoundError: No module named 'src'
Same interpreter, same directory, same code. The script finds src; pytest does not.
The reason is that pytest does not run your script β it imports your test file, and it does that with a sys.path it builds itself. Whatever made import src work when you ran python src/pipeline.py is not in effect.
This is not a GIS-specific problem, but it bites GIS projects harder than most, because the workaround people reach for β sys.path.append at the top of the test β interacts badly with conftest fixtures, parallel runs and the heavy native imports GIS packages do.
Quick answer
Make the project installable and install it in editable mode. This is the fix; everything else is a workaround.
# pyproject.toml
[project]
name = "gis-pipeline"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["geopandas", "rasterio"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pip install -e .
pytest # imports work everywhere: tests, notebooks, CI, the app
With src/ as the package root, imports drop the prefix:
from transforms import buffer_metres # not src.transforms
| Symptom | Cause | Fix |
|---|---|---|
No module named 'src' |
project not installed, sys.path lacks the root |
pip install -e . |
| works in terminal, fails in the IDE | IDE using a different interpreter | point the IDE at .venv/bin/python |
ImportError: attempted relative import |
test run as a script, not a module | run pytest, not python tests/x.py |
| two test files, same name | pytest cannot distinguish them without __init__.py |
unique names, or add __init__.py |
| passes alone, fails in the suite | import order or shared state | see step 5 |
| CI only | different working directory | pip install -e . in CI too |
What pytest actually does to sys.path
Step-by-step solution
1. Understand why it works for the script and not for pytest
When you run python src/pipeline.py, Python puts the script's directory on sys.path. When you run python -m src.pipeline, it puts the current directory on sys.path. When pytest imports tests/test_transforms.py, it does neither β it inserts the test file's rootdir for that file, determined by walking up from the file until it stops finding __init__.py.
# see it for yourself
pytest --collect-only -q
python -c "import sys; print('\n'.join(sys.path))"
So the fix is not to persuade pytest to guess your layout. It is to make import transforms work regardless of who is doing the importing β which means installing the package.
2. Adopt the src layout and install it
project/
βββ pyproject.toml
βββ src/
β βββ transforms.py
β βββ pipeline.py
β βββ io.py
βββ tests/
βββ conftest.py
βββ test_transforms.py
βββ fixtures/
βββ parcels.gpkg
pip install -e .
-e (editable) installs a link rather than a copy, so edits take effect without reinstalling. What it actually does is put your package on sys.path permanently for that environment β which is why tests, notebooks, the CLI and CI all agree afterwards.
The src layout has a specific benefit for testing: because the package is not in the repository root, tests cannot accidentally import it from the working directory. They import the installed version, which is the version users get. A test that passes only because the module happened to be in the current directory is a test that lies.
3. If you cannot install it, use pythonpath β not sys.path.append
pytest 7.0+ can add paths from config:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
# or pytest.ini for older versions
[pytest]
pythonpath = src
testpaths = tests
This is declarative, applies to every test, and lives in one file. Compare with the version people write instead:
# tests/test_transforms.py β do not do this
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from transforms import buffer_metres
That has to be repeated in every test file, breaks when a file moves, runs before fixtures are collected, and mutates global state differently depending on collection order. It works right up until it does not.
4. Put shared fixtures in conftest.py, at the right level
# tests/conftest.py β discovered automatically, no import needed
import pytest
import geopandas as gpd
from shapely.geometry import Point
from pathlib import Path
FIXTURES = Path(__file__).parent / "fixtures"
@pytest.fixture
def parcels():
return gpd.read_file(FIXTURES / "parcels.gpkg")
@pytest.fixture
def points_27700():
return gpd.GeoDataFrame(
{"id": [1, 2]},
geometry=[Point(325000, 674000), Point(326000, 675000)],
crs="EPSG:27700",
)
conftest.py needs no import statement anywhere β pytest finds it by walking up from each test file. Note Path(__file__).parent for the fixture directory: a relative path like "fixtures/parcels.gpkg" resolves against the working directory, so it works from the project root and fails from anywhere else. That is the same bug as relative paths breaking in a scheduled script, one context over.
5. Fix "passes alone, fails in the suite"
If pytest tests/test_a.py passes and pytest fails, the import itself is fine and something is shared. The usual causes, in order of likelihood:
# a) two test files with the same basename and no __init__.py
tests/unit/test_io.py
tests/integration/test_io.py # pytest cannot tell them apart
# b) a session-scoped fixture that a test mutates
@pytest.fixture(scope="session")
def parcels():
return gpd.read_file(FIXTURE) # test 1 adds a column, test 2 sees it
# c) a module-level import with a side effect
from src.config import load # reads an env var at import time
Fixes: give test files unique names (or add __init__.py to the test directories), make mutable fixtures function-scoped, and keep import-time side effects out of modules.
@pytest.fixture # function scope: fresh copy per test
def parcels(parcels_source):
return parcels_source.copy()
@pytest.fixture(scope="session") # the expensive read happens once
def parcels_source():
return gpd.read_file(FIXTURE)
That pairing gives you one file read and independent tests, which is what people are usually reaching for when they set scope="session".
6. Make CI do exactly what you do
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install --only-binary=:all: -e ".[dev]"
- run: pytest -q
The -e . is the important line β without it, CI hits the same ModuleNotFoundError on a fresh checkout that a new colleague hits on clone.
[project.optional-dependencies]
dev = ["pytest>=8", "pytest-cov"]
Code examples
Example 1: a working project skeleton
project/
βββ pyproject.toml
βββ src/
β βββ gispipe/
β βββ __init__.py
β βββ transforms.py
β βββ pipeline.py
βββ tests/
βββ conftest.py
βββ fixtures/parcels.gpkg
βββ test_transforms.py
βββ test_pipeline.py
[project]
name = "gispipe"
version = "0.1.0"
dependencies = ["geopandas>=1.0", "rasterio>=1.3"]
[project.optional-dependencies]
dev = ["pytest>=8"]
[project.scripts]
gispipe = "gispipe.pipeline:main" # a CLI, from the same package
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"
markers = ["slow: needs real I/O or a database"]
# tests/test_transforms.py
from gispipe.transforms import buffer_metres # unambiguous, installed
def test_buffer_uses_metres(points_27700):
out = buffer_metres(points_27700, 50)
assert out.crs == points_27700.crs
assert out.geometry.area.iloc[0] > 7800
One named package, one import path, no path manipulation anywhere.
Example 2: diagnosing the import in thirty seconds
# tests/test_zzz_diagnostics.py β delete once it passes
import sys, pathlib
def test_show_import_context():
print("\nexecutable:", sys.executable)
print("cwd: ", pathlib.Path.cwd())
print("sys.path:")
for p in sys.path:
print(" ", p)
try:
import gispipe
print("gispipe at:", gispipe.__file__)
except ImportError as exc:
print("gispipe NOT importable:", exc)
pytest tests/test_zzz_diagnostics.py -s
Two lines of that output answer almost every case. If executable is not your .venv, the IDE or shell is using the wrong interpreter. If gispipe.__file__ points into site-packages rather than your src/, you installed a copy instead of an editable link.
Example 3: keeping slow GIS tests out of the fast loop
# tests/test_pipeline.py
import pytest
@pytest.mark.slow
def test_full_run_against_real_files(tmp_path, sample_folder):
results = run(sample_folder, tmp_path)
assert all(r.status == "ok" for r in results)
pytest -m "not slow" # the loop you run constantly
pytest # everything, before pushing
Worth doing early in GIS projects specifically: importing geopandas and rasterio takes a second or two, reading real files takes longer, and a suite that takes four minutes stops being run.
Explanation
Python resolves import x by searching sys.path in order. What populates sys.path depends entirely on how the process started:
python script.pyβ the script's directory goes firstpython -m package.moduleβ the current working directory goes firstpytestβ pytest inserts rootdirs it computes per test file- a notebook β the notebook's directory
- an installed console script β only
site-packages
Five entry points, five different answers. Any fix that targets one of them β sys.path.append in a test file, or running pytest only from the project root β leaves the other four broken. That is why "make the package installable" is the real answer rather than a purist one: an installed package is on sys.path for every entry point, so the question stops being asked.
pytest's own rootdir algorithm is worth knowing because it explains the weirder symptoms. For each test file, pytest walks up the directory tree as long as it keeps finding __init__.py, and inserts the first directory that does not have one. With no __init__.py anywhere in tests/, that means tests/ itself goes on the path β which is why tests/unit/test_io.py and tests/integration/test_io.py collide: both are imported as top-level module test_io, and the second import silently gets the first module.
The src layout addresses a subtler failure. With packages at the repository root, import gispipe succeeds from the project directory whether or not the package is installed, and whether or not pyproject.toml lists the files correctly. Tests pass; the wheel ships broken. Moving the package under src/ removes the working directory from the equation, so the tests exercise the installed artefact β which is what users get.
Edge cases or notes
pip install -e .needspyproject.tomlorsetup.py. A bare folder of scripts is not a package; that is the actual problem being fixed.- Editable installs and namespace packages interact badly. If
src/contains modules with no__init__.py, use[tool.setuptools] py-modulesor add the file. conftest.pyat the repo root is also collected, and is a reasonable place forpythonpathin older setups β but not for fixtures only some tests need.pytestandpython -m pytestdiffer. The second adds the current directory tosys.path, which can mask the problem locally and fail in CI.--import-mode=importlib(pytest 6+) avoids thesys.pathinsertion entirely and is the cleanest mode for an installed package.- Jupyter needs the same install.
pip install -e .from inside the kernel's environment, not from a terminal that may be using a different one. tmp_pathis per-test and cleaned automatically;tmp_path_factoryis session-scoped when a fixture builds something expensive.- Heavy imports at module level slow collection. Importing rasterio in
conftest.pycosts a second on every run, includingpytest --collect-only.
Internal links
- How to test a GIS pipeline with pytest β the suite this page unblocks
- What to test in a GIS pipeline β what to put in it once imports work
- GIS tests pass locally but fail in CI β the next failure after this one
- How to set up a Python GIS environment that actually works β the interpreter the IDE should be pointing at
- ModuleNotFoundError in a scheduled GIS job β the same import problem under cron
- Relative paths break when a GIS script runs automatically β why fixture paths need
Path(__file__) - How to turn a GIS script into a command-line tool with argparse β the CLI that the same package layout gives you
- How to run a Python GIS pipeline in CI with GitHub Actions β where
pip install -e .belongs
FAQ
Why does my script find the module but pytest does not?
python script.py puts the script's own directory on sys.path. pytest imports test files with a path it computes itself, which does not include your source directory. Installing the package makes both work.
Is sys.path.append in a test file ever acceptable?
For a one-off spike, maybe. In a repository, no β it has to be repeated in every file, breaks on moves, and behaves differently depending on collection order. Use pythonpath in config, or install the package.
Do I need __init__.py in my tests directory?
Only if you have test files with the same basename in different directories. Otherwise pytest handles it, and adding them changes how rootdir is computed.
What is the src layout for?
It stops tests importing your package from the working directory, so they exercise the installed version β the one users get. It turns "works on my machine" into "works when installed".
Why does the IDE fail when the terminal works?
The IDE is almost certainly using a different interpreter. Compare sys.executable in both and point the IDE at your .venv.
Does pip install -e . need repeating after every edit?
No. An editable install links to your source, so changes take effect immediately. Reinstall only when dependencies or entry points change.
How do I stop the test suite from being slow?
Mark tests that touch real files or databases with @pytest.mark.slow and run pytest -m "not slow" during development. GIS imports and I/O are the usual cost.