pytest Cannot Import My GIS Modules: How to Fix It

On this page

Problem statement

The pipeline runs. The tests do not.

$ python -m src.pipeline
processed 40 parcels

$ pytest
ImportError while importing test module 'project/tests/test_transforms.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
...
tests/test_transforms.py:2: in <module>
    from src.transforms import buffer_metres
E   ModuleNotFoundError: No module named 'src'

Same interpreter, same directory, same import line. The pipeline finds src; pytest does not. And python -m pytest, from the same place, passes.

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 -m src.pipeline is not in effect. Every transcript on this page is from a real project built and tested in the code examples below, with 40 Rutland land parcels as the test fixture.

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 β€” is repeated per file, leaks into notebooks and CI as a different failure, and hides the packaging mistake that the installed version of your pipeline will then ship with.

Quick answer

Make the project an installable package and install it in editable mode. This is the fix; everything else is a workaround.

project/
β”œβ”€β”€ pyproject.toml
β”œβ”€β”€ src/
β”‚   └── gispipe/
β”‚       β”œβ”€β”€ __init__.py
β”‚       └── transforms.py
└── tests/
    └── test_transforms.py
# pyproject.toml
[project]
name = "gispipe"
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
from gispipe.transforms import buffer_metres      # not src.transforms

The package directory with an __init__.py is not decoration. With the modules loose in src/, pip install -e . still made the tests pass β€” but the wheel built from the same project contained no code at all (example 2).

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
attempted relative import with no known parent package test run as a script, not a module run pytest, not python tests/x.py
import file mismatch two test files with the same basename unique names, __init__.py, or --import-mode=importlib
passes alone, fails in the suite shared mutable fixture or import-time state see step 5
CI only different working directory, nothing installed pip install -e . in CI too

What pytest actually does to sys.path

How pytest determines rootdir and builds sys.path for a test module. pytest builds its own sys.pathfind the testfilewalk up past__init__.pyinsert thatdirimport themoduleyour src/ isabsentpython script.py inserts a different directory entirely spatialworkflow.io
pytest walks up from the test file to find a package root, then inserts that β€” not your project root.

Why the script imports and pytest does not, and the src layout

Each pytest import symptom paired with its cause and fix. five symptoms, one real fixSymptomFixNo module named 'src'pip install -e . β€” the actual fixworks in terminal, not the IDEpoint the IDE at .venv/bin/pythontwo test files, same basenameunique names, or add __init__.pyattempted relative importrun pytest, not python tests/x.pypasses alone, fails in the suitefunction-scoped fixtures, tmp_pathOnly the first row is fixed once and for every entry point spatialworkflow.io
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 β€” src/ β€” on sys.path, so from src.transforms import … fails there too. When you run python -m src.pipeline, it puts the current directory on sys.path, and the import works. When pytest imports tests/test_transforms.py in its default mode, it does neither: it walks up from the test file while it keeps finding __init__.py, and inserts the first directory without one β€” its basedir. With no __init__.py in tests/, that is tests/ itself.

# 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 the import work regardless of who is doing the importing β€” which means installing the package.

The same import, from src.transforms, run four ways from the project root. same checkout, four answersdirectory added to sys.pathresultpython src/pipeline.pyproject/srcβœ•No module named 'src'python -m src.pipelineproject/ (cwd)βœ“processed 40 parcelspytestproject/tests (basedir)βœ•No module named 'src'python -m pytestproject/ and project/testsβœ“1 passedOnly the two runs that add the working directory find src spatialworkflow.io
Example 1, summarised: the result follows whichever directories each entry point put on sys.path.

2. Adopt the src layout and install it

project/
β”œβ”€β”€ pyproject.toml
β”œβ”€β”€ src/
β”‚   └── gispipe/
β”‚       β”œβ”€β”€ __init__.py
β”‚       β”œβ”€β”€ transforms.py
β”‚       └── pipeline.py
└── tests/
    β”œβ”€β”€ conftest.py
    β”œβ”€β”€ test_transforms.py
    └── fixtures/
        └── parcels.gpkg
pip install -e .

-e (editable) installs a pointer rather than a copy, so edits take effect without reinstalling. With setuptools and this layout the pointer is a .pth file in site-packages containing the path to src/ β€” which is why tests, notebooks, the CLI and CI all agree afterwards.

That .pth file is also the layout's blind spot. Because it puts the whole of src/ on the path, anything in src/ imports in an editable install, whether or not the packaging configuration would ship it. The src layout stops tests importing your code from the working directory; it does not stop them importing code the wheel leaves out. Build the wheel, or install with plain pip install . in CI, to check that.

3. If you cannot install it, use pythonpath β€” not sys.path.append

pytest 7.0 and later can add paths from config:

[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
# the same in pytest.ini β€” still needs pytest 7.0 or later
[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 gispipe.transforms import buffer_metres

That has to be repeated in every test file, breaks when a file moves, and fixes only pytest: the notebook, the CLI and the next colleague's IDE still cannot import the package.

4. Put shared fixtures in conftest.py, at the right level

# tests/conftest.py β€” discovered automatically, no import needed
from pathlib import Path

import geopandas as gpd
import pytest

FIXTURES = Path(__file__).parent / "fixtures"

@pytest.fixture
def parcels():
    return gpd.read_file(FIXTURES / "parcels.gpkg")

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 "tests/fixtures/parcels.gpkg" resolves against the working directory, so it works from the project root and fails from anywhere else (example 3). 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:

# a) two test files with the same basename and no __init__.py
tests/unit/test_io.py
tests/integration/test_io.py        # pytest stops with "import file mismatch"

# 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 gispipe.config import load     # reads an env var at import time

Fixes: give test files unique names (or add __init__.py to the test directories, or use --import-mode=importlib), 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" (example 5).

How a session-scoped fixture made a test pass alone and fail in the suite, as run in Example 5. one frame, shared by every test1session fixture reads parcels.gpkgonce, 40 parcels2test_a_area adds area_m2to the shared frame3test_b_schema: area_m2 absent?alone: 1 passed; in the suite: failed4function fixture returns .copy()read once, 3 passedKeep the expensive read in session scope and hand each test its own copy spatialworkflow.io
Example 5, step by step: the read is shared on purpose, the mutation by accident.

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. --only-binary=:all: refuses source builds of the GIS dependencies, and it does not stop pip building your own project's editable install. To catch the empty-wheel mistake as well, add a job that runs the tests against pip install . without -e.

[project.optional-dependencies]
dev = ["pytest>=8", "pytest-cov"]

The import failure reproduced in a fresh project

The data used on this page

Each example builds a small project in project/, runs real commands in a fresh virtual environment (geopandas and pytest installed from PyPI), and prints what they printed, with the project's absolute path shortened to project. The test fixture is the first 40 land parcels in HM Land Registry's INSPIRE Index Polygons for Rutland (Β© Crown copyright and database rights, HM Land Registry; Open Government Licence).

import re
import shlex
import shutil
import subprocess
import sys
import textwrap
import venv
import zipfile
from pathlib import Path

import geopandas as gpd
import requests

LAB, ENV = Path("project").resolve(), Path("env").resolve()
if not (ENV / "bin" / "pytest").exists():
    venv.create(ENV, with_pip=True)
    subprocess.run([ENV / "bin/pip", "install", "-q", "geopandas", "pytest"], check=True)

zip_path = Path("data/Rutland.zip")
if not zip_path.exists():
    zip_path.parent.mkdir(exist_ok=True)
    zip_path.write_bytes(requests.Session().get(
        "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip", timeout=300).content)
sample = gpd.read_file(f"/vsizip/{zip_path}/Land_Registry_Cadastral_Parcels.gml", rows=40)


def fresh():
    shutil.rmtree(LAB, ignore_errors=True)
    (LAB / "tests/fixtures").mkdir(parents=True)
    sample.to_file(LAB / "tests/fixtures/parcels.gpkg")


def write(rel, text):
    path = LAB / rel
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(textwrap.dedent(text).lstrip())


def run(*cmd, cwd=LAB, pick=None):
    """Run a command with the environment's python/pip/pytest; print the telling lines."""
    exe = [str(ENV / "bin" / cmd[0]) if cmd[0] in ("python", "pip", "pytest") else cmd[0], *cmd[1:]]
    out = subprocess.run(exe, cwd=cwd, capture_output=True, text=True)
    text = (out.stdout + out.stderr).replace(str(LAB), "project").replace(str(ENV), ".venv")
    lines = [l for l in text.splitlines() if l.strip()]
    shown = [l for l in lines if pick and re.search(pick, l)] + lines[-1:]
    print(f"$ {shlex.join(cmd)}" + ("" if cwd == LAB else "   (from the directory above project/)"))
    for line in dict.fromkeys(shown):
        print("  ", line)


versions = subprocess.run([ENV / "bin/python", "-c", "import geopandas, pytest, sys; "
                           "print(sys.version.split()[0], geopandas.__version__, pytest.__version__)"],
                          capture_output=True, text=True).stdout.split()
print("Python {}, geopandas {}, pytest {}".format(*versions))
print(len(sample), "Rutland parcels,", sample.crs.to_string())
Python 3.14.4, geopandas 1.2.0, pytest 9.1.1
40 Rutland parcels, EPSG:27700

Example 1: The failure, reproduced four ways

fresh()
write("src/transforms.py", '''
    def buffer_metres(gdf, metres):
        """Buffer in British National Grid, whatever CRS the input arrived in."""
        return gdf.to_crs(27700).buffer(metres)
''')
write("src/pipeline.py", '''
    import geopandas as gpd
    from src.transforms import buffer_metres
    parcels = gpd.read_file("tests/fixtures/parcels.gpkg")
    print(f"processed {len(buffer_metres(parcels, 10))} parcels")
''')
write("tests/test_transforms.py", '''
    import geopandas as gpd
    from src.transforms import buffer_metres

    def test_buffer_grows_every_parcel():
        parcels = gpd.read_file("tests/fixtures/parcels.gpkg")
        assert (buffer_metres(parcels, 10).area > parcels.area).all()
''')
run("python", "src/pipeline.py", pick=r"^\w*Error")
run("python", "-m", "src.pipeline")
run("pytest", "-q", pick=r"^E ")
run("python", "-m", "pytest", "-q")
$ python src/pipeline.py
   ModuleNotFoundError: No module named 'src'
$ python -m src.pipeline
   processed 40 parcels
$ pytest -q
   E   ModuleNotFoundError: No module named 'src'
   1 error in 0.33s
$ python -m pytest -q
   1 passed in 0.25s

The script run directly fails too, because its own directory is src/. python -m and python -m pytest both put the working directory on the path, which is why they work β€” and why a teammate who types pytest instead gets a different answer from the same checkout.

Example 2: The editable install that hides an empty wheel

The obvious fix: add a pyproject.toml, point setuptools at src/, install editable, and drop the src. prefix.

write("pyproject.toml", '''
    [project]
    name = "gis-pipeline"
    version = "0.1.0"
    dependencies = ["geopandas"]

    [build-system]
    requires = ["setuptools>=61"]
    build-backend = "setuptools.build_meta"

    [tool.setuptools.packages.find]
    where = ["src"]
''')
for rel in ("src/pipeline.py", "tests/test_transforms.py"):
    (LAB / rel).write_text((LAB / rel).read_text().replace("from src.transforms", "from transforms"))

run("pip", "install", "-q", "-e", ".")
run("pytest", "-q")
print(".pth:", *(p.read_text().strip().replace(str(LAB), "project")
                 for p in (ENV / "lib").glob("python3*/site-packages/__editable__*gis_pipeline*.pth")))

run("pip", "wheel", "-q", "--no-deps", "-w", "dist", ".")
wheel = next((LAB / "dist").glob("*.whl"))
print(wheel.name, "contains:", [n for n in zipfile.ZipFile(wheel).namelist() if not n.startswith("gis_pipeline-")])
$ pip install -q -e .
$ pytest -q
   1 passed in 0.24s
.pth: project/src
$ pip wheel -q --no-deps -w dist .
gis_pipeline-0.1.0-py3-none-any.whl contains: []

The tests pass, and the wheel has nothing in it: packages.find looks for packages, found none among loose modules, and the editable install worked only because its .pth file put all of src/ on the path. Anyone who installed this project normally would get No module named 'transforms'. Moving the modules into a named package fixes both:

run("pip", "uninstall", "-q", "-y", "gis-pipeline")
shutil.rmtree(LAB / "dist")
(LAB / "src/gispipe").mkdir()
for name in ("transforms.py", "pipeline.py"):
    (LAB / "src" / name).rename(LAB / "src/gispipe" / name)
(LAB / "src/gispipe/__init__.py").write_text("")
for rel in ("src/gispipe/pipeline.py", "tests/test_transforms.py"):
    (LAB / rel).write_text((LAB / rel).read_text().replace("from transforms", "from gispipe.transforms"))
(LAB / "pyproject.toml").write_text((LAB / "pyproject.toml").read_text().replace('"gis-pipeline"', '"gispipe"'))

run("pip", "install", "-q", "-e", ".")
run("pytest", "-q")
run("python", "-c", 'import gispipe; print("gispipe at:", gispipe.__file__)')
run("pip", "wheel", "-q", "--no-deps", "-w", "dist", ".")
wheel = next((LAB / "dist").glob("*.whl"))
print(wheel.name, "contains:", [n for n in zipfile.ZipFile(wheel).namelist() if not n.startswith("gispipe-")])
$ pip uninstall -q -y gis-pipeline
$ pip install -q -e .
$ pytest -q
   1 passed in 0.27s
$ python -c 'import gispipe; print("gispipe at:", gispipe.__file__)'
   gispipe at: project/src/gispipe/__init__.py
$ pip wheel -q --no-deps -w dist .
gispipe-0.1.0-py3-none-any.whl contains: ['gispipe/__init__.py', 'gispipe/pipeline.py', 'gispipe/transforms.py']

gispipe.__file__ pointing into src/ rather than site-packages is how you know the install is editable; the wheel's file list is how you know it would work if it were not.

Two src layouts, installed editable and built as a wheel: whether the tests passed and what the wheel held. the tests passing is not the wheel workingloose modules in src/src/gispipe/ packagepip install -e . then pytest1 passed1 passedwhat the editable .pth addsproject/srcproject/srcfiles in the built wheelnone3 modulesa normal pip install .no module to importimportspackages.find finds packages; loose modules need py-modules or a packageThe editable install puts all of src/ on the path, so it cannot see a packaging mistake spatialworkflow.io
Measured in Example 2: an editable install cannot tell these two projects apart; the wheel can.

Example 3: Fixture paths that only work from one directory

The test above opens "tests/fixtures/parcels.gpkg". Run the same suite from the directory above the project, as an IDE or a CI step with a different working directory will:

run("pytest", "-q", "project/tests", cwd=LAB.parent, pick=r"^E .*Error")

write("tests/conftest.py", '''
    from pathlib import Path

    import geopandas as gpd
    import pytest

    FIXTURES = Path(__file__).parent / "fixtures"

    @pytest.fixture
    def parcels():
        return gpd.read_file(FIXTURES / "parcels.gpkg")
''')
write("tests/test_transforms.py", '''
    from gispipe.transforms import buffer_metres

    def test_buffer_grows_every_parcel(parcels):
        assert (buffer_metres(parcels, 10).area > parcels.area).all()
''')
run("pytest", "-q", "project/tests", cwd=LAB.parent)
$ pytest -q project/tests   (from the directory above project/)
   E   pyogrio.errors.DataSourceError: tests/fixtures/parcels.gpkg: No such file or directory
   1 failed in 0.29s
$ pytest -q project/tests   (from the directory above project/)
   1 passed in 0.04s

The import is fine from anywhere now that the package is installed. The data path was the remaining dependency on the working directory, and the conftest fixture removes it.

Example 4: Two test files with the same name

for kind in ("unit", "integration"):
    write(f"tests/{kind}/test_io.py", f'''
        def test_{kind}_reads(parcels):
            assert len(parcels) == 40
    ''')
run("pytest", "-q", pick=r"import file mismatch")
run("pytest", "-q", "--import-mode=importlib")
for kind in ("unit", "integration"):
    (LAB / f"tests/{kind}/__init__.py").write_text("")
(LAB / "tests/__init__.py").write_text("")
run("pytest", "-q")
$ pytest -q
   import file mismatch:
   1 error in 0.09s
$ pytest -q --import-mode=importlib
   3 passed in 0.06s
$ pytest -q
   3 passed in 0.07s

pytest does not silently use the wrong module; it stops collection with import file mismatch. Either of the other two modes fixes it. Adding __init__.py makes pytest import tests.unit.test_io and tests.integration.test_io as distinct packages; importlib mode stops inserting directories into sys.path at all, which is also why, in that mode, a test cannot import a sibling helper module unless it is part of an installed package.

Example 5: A shared fixture that passes alone and fails in the suite

shutil.rmtree(LAB / "tests/unit"), shutil.rmtree(LAB / "tests/integration")
write("tests/conftest.py", '''
    from pathlib import Path

    import geopandas as gpd
    import pytest

    FIXTURES = Path(__file__).parent / "fixtures"

    @pytest.fixture(scope="session")
    def parcels():
        return gpd.read_file(FIXTURES / "parcels.gpkg")
''')
write("tests/test_a_area.py", '''
    def test_area_column(parcels):
        parcels["area_m2"] = parcels.area          # mutates the shared frame
        assert parcels["area_m2"].gt(0).all()
''')
write("tests/test_b_schema.py", '''
    def test_schema_is_as_delivered(parcels):
        assert "area_m2" not in parcels.columns
''')
run("pytest", "-q", "tests/test_b_schema.py")
run("pytest", "-q", pick=r"^FAILED")

(LAB / "tests/conftest.py").write_text((LAB / "tests/conftest.py").read_text().replace(
    '@pytest.fixture(scope="session")\ndef parcels():',
    '@pytest.fixture(scope="session")\ndef parcels_source():') + textwrap.dedent('''

    @pytest.fixture
    def parcels(parcels_source):
        return parcels_source.copy()
'''))
run("pytest", "-q")
$ pytest -q tests/test_b_schema.py
   1 passed in 0.04s
$ pytest -q
   FAILED tests/test_b_schema.py::test_schema_is_as_delivered - AssertionError: ...
   1 failed, 2 passed in 0.09s
$ pytest -q
   3 passed in 0.06s

The file is read once per session either way; only the copy is per test.

Example 6: What GIS imports cost the suite

write("tests/test_slow.py", '''
    import pytest

    @pytest.mark.slow
    def test_full_county(parcels):
        assert parcels.union_all().area > 0
''')
(LAB / "pyproject.toml").write_text((LAB / "pyproject.toml").read_text() + textwrap.dedent('''
    [tool.pytest.ini_options]
    testpaths = ["tests"]
    markers = ["slow: needs real I/O or a database"]
'''))
run("pytest", "-q", "-m", "not slow")
run("python", "-c", 'import time; t = time.perf_counter(); import geopandas; '
    'print(f"import geopandas: {time.perf_counter() - t:.2f} s")')
$ pytest -q -m 'not slow'
   3 passed, 1 deselected in 0.08s
$ python -c 'import time; t = time.perf_counter(); import geopandas; print(f"import geopandas: {time.perf_counter() - t:.2f} s")'
   import geopandas: 0.33 s

Importing geopandas cost well under a second here, and reading a 40-parcel fixture is nothing. The case for a slow marker in a GIS suite is not the imports; it is the test that reads a county, or a database, or a raster tile set, and turns a suite you run on every save into one you run before lunch.

How sys.path is filled

sys.path manipulation contrasted with an editable install. both work todaysys.path.insert in the testβœ•repeated in every test fileβœ•breaks when a file movesβœ•fixes pytest onlyβœ•notebooks still failβœ•CI still failspip install -e .βœ“one line, one timeβœ“survives moves and renamesβœ“tests, notebooks, CLI, CI agreeβœ“one pointer in site-packagesβœ“src layout stops accidental importsFive entry points build sys.path five different ways spatialworkflow.io
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 the basedir it computes for each 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 default import mode, prepend, explains the weirder symptoms. For each test file it walks up the directory tree as long as it keeps finding __init__.py, inserts the first directory that does not have one, and imports the file under the dotted name from there. With no __init__.py anywhere in tests/, tests/unit/test_io.py and tests/integration/test_io.py are both the top-level module test_io, and pytest refuses the second rather than guess.

The src layout addresses a subtler failure than it is usually credited with, and less of one. With packages at the repository root, import gispipe succeeds from the project directory whether or not the package is installed β€” so tests can pass against code the installation never sees. Moving the code under src/ removes the working directory from the equation. But an editable install puts src/ itself on the path, so the tests still see everything there, packaged or not. The last check has to be against a real build.

Editable installs, loose modules and import modes

  • 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.
  • Loose modules in src/ are invisible to packages.find. List them with [tool.setuptools] py-modules = ["transforms"], or put them in a package β€” otherwise the editable install works and the wheel is empty.
  • A conftest.py at the repository root puts the root on sys.path in the default import mode, which makes from src.transforms import … work again. It is a common accidental fix; it is still not an install.
  • 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.0 and later) 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; tmp_path_factory is session-scoped when a fixture builds something expensive.

FAQ

Why does my script find the module but pytest does not?

python -m package.module puts the current directory on sys.path, and python script.py puts the script's own directory there. pytest imports test files with a path it computes itself, which does not include your source directory. Installing the package makes every entry point agree.

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 β€” without it pytest stops with import file mismatch. Adding them changes the dotted name each test module is imported under; --import-mode=importlib is the alternative that needs none.

What is the src layout for?

It stops tests importing your package from the working directory, so they only pass once the package is installed. An editable install still exposes everything under src/, so build the wheel or test a normal install in CI to prove the package ships what the tests used.

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. I/O is the usual cost; importing geopandas took well under a second in the run on this page.