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

Flow showing how pytest determines rootdir, inserts sys.path entries and imports a test module.
pytest walks up from the test file to find a package root, then inserts that β€” not your project root.

Step-by-step solution

Triage rows pairing each import symptom with its cause and its fix.
Only the first row has a proper fix; the rest are configuration.

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

Two panels contrasting sys.path manipulation with an editable install.
Both make the import work today. Only one keeps working from a notebook, a CLI and CI.

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 first
  • python -m package.module β†’ the current working directory goes first
  • pytest β†’ 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 . needs pyproject.toml or setup.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-modules or add the file.
  • conftest.py at the repo root is also collected, and is a reasonable place for pythonpath in older setups β€” but not for fixtures only some tests need.
  • pytest and python -m pytest differ. The second adds the current directory to sys.path, which can mask the problem locally and fail in CI.
  • --import-mode=importlib (pytest 6+) avoids the sys.path insertion 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_path is per-test and cleaned automatically; tmp_path_factory is session-scoped when a fixture builds something expensive.
  • Heavy imports at module level slow collection. Importing rasterio in conftest.py costs a second on every run, including pytest --collect-only.

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.