DEV Community

Taylor Wang
Taylor Wang

Posted on

The Fixture Path Was Relative. The Agent Never Stayed at the Repo Root.

Have you ever watched a remote pytest run go green while the same command failed on your laptop? I spent forty-eight hours in that gap, writing field notes instead of trusting another agent transcript. The bug was not the fixture file, the Python version, or even the installed package list. The agent had changed directory before launching pytest, and every relative path quietly followed that cwd.

Hour 0: the question I should have asked first

I started with a tiny loader that opened data/events.json using a path relative to the process. Locally I always launched pytest from the repository root, so the file resolved and the assertions passed. On the remote session the helper preferred changing into tests/ first, then running a single test file. Why did I blame pytest collection, pathlib, and the coding model before printing os.getcwd() even once?

Here is the original shape of the loader, labeled as a reconstructed example rather than a production dump:

# reconstructed example — do not treat as a captured incident log
from pathlib import Path
import json

def load_events() -> list[dict]:
    # This follows the process cwd, not the file that contains the function.
    raw = Path("data/events.json").read_text(encoding="utf-8")
    return json.loads(raw)
Enter fullscreen mode Exit fullscreen mode

That function is honest about one thing only: it trusts whoever launched Python to stand in the right directory. I trusted that too, because my muscle memory types pytest from the repository root without thinking. Remote agents do not share that muscle memory, especially when a transcript starts with cd tests to focus the run. Have you checked whether your last green remote command even printed pwd beside the pytest summary?

What I tried during the first twelve hours

I copied the failing test into a scratch folder and reran it with python -m pytest -q. I printed sys.version, sys.executable, and a short pip freeze because those checks had saved me before. Those values matched between the laptop and the remote shell, which made the mismatch feel supernatural. Matching interpreters do not match working directories, and I needed a long evening to accept that distinction.

I also iterated on the loader using MonkeyCode free model access and the free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach, not as an independent lab study. The generated patch still opened Path("data/events.json"), which looks tidy until cwd is no longer the repository root. I never asked where that free server started the shell, because the transcript said green and green anesthetizes tired eyes.

Dead ends from the first night

During those first hours I also tried these dead ends, each of which felt reasonable at the time:

  1. Reinstalling the project with python -m pip install -e . so imports would resolve from any directory.
  2. Forcing pytest --import-mode=importlib because import mode has been a red herring for me before.
  3. Switching from pytest tests/test_events.py to python -m pytest tests/test_events.py for module path reasons.
  4. Exporting PYTHONPATH=. in the remote shell, which fixed imports and still left the JSON path broken.

Editable installs repair import paths, yet they do not repair Path("data/events.json"), because that object never consults sys.path. PYTHONPATH has the same blind spot, which is why the fourth attempt felt like progress and then stalled. Would you have noticed that distinction without printing both sys.path[0] and os.getcwd() on the same line?

Hours 12–24: what actually broke

The remote command that looked professional was roughly this, reconstructed from the transcript rather than copied as a log. Notice there is no pwd receipt and no return to the repository root after the cd. I treated that omission as style, which is how casual shell style becomes a confusing outage. The block below is labeled reconstruction, not a captured CI log from any named company.

cd tests
python -m pytest test_events.py -q
Enter fullscreen mode Exit fullscreen mode

From that nested cwd, Path("data/events.json") looks for tests/data/events.json, which does not exist in this repository layout. pytest still collected the test module, so the failure presented as FileNotFoundError inside the assertion helper, not as a collection error. I spent hours reading pytest docs about rootpath and rootdir, which matter, but they do not rewrite ad-hoc Path("data/...") calls. Relative paths are a process contract, not a pytest fixture, and I had mixed those two ideas.

The layout that made the bug cheap to miss is small enough to paste, and it looked like this tree.

repo/
  data/events.json
  src/app/events.py
  tests/test_events.py
Enter fullscreen mode Exit fullscreen mode

When I ran from repo/, the relative path accidentally worked, which is the worst kind of green. When the agent ran from tests/, the same path became a missing file with a stack trace that mentioned my helper, not the shell. I kept patching the helper while the working directory kept moving underneath every new process I launched. Does that sound like a model failure to you, or like a missing assertion on process cwd?

A probe I now run before I trust a green remote suite

I want a receipt that survives copy-paste into a laptop, a container, or a free remote server. The script below is a proposed probe, not a benchmark, and it prints the facts I wish I had captured at hour zero. It does not call a network API, and it does not depend on a particular coding product. Paste it, run it, and compare the JSON between machines before you debate the failing test.

# path_probe.py — proposed local check, not a captured production metric
from __future__ import annotations

import json
import sys
from pathlib import Path

REPO_MARKERS = ("pyproject.toml", "setup.cfg", "pytest.ini", ".git")


def find_repo_root(start: Path) -> Path | None:
    cur = start.resolve()
    for candidate in (cur, *cur.parents):
        if any((candidate / marker).exists() for marker in REPO_MARKERS):
            return candidate
    return None


def probe(fixture_rel: str = "data/events.json") -> dict:
    cwd = Path.cwd().resolve()
    here = Path(__file__).resolve().parent
    root = find_repo_root(here) or find_repo_root(cwd)
    cwd_path = cwd / fixture_rel
    file_path = here / fixture_rel
    root_path = (root / fixture_rel) if root else None
    return {
        "cwd": str(cwd),
        "argv0": sys.argv[0],
        "executable": sys.executable,
        "file": str(Path(__file__).resolve()),
        "repo_root": str(root) if root else None,
        "cwd_fixture_exists": cwd_path.is_file(),
        "file_sibling_exists": file_path.is_file(),
        "root_fixture_exists": bool(root_path and root_path.is_file()),
        "cwd_fixture": str(cwd_path),
        "root_fixture": str(root_path) if root_path else None,
    }


if __name__ == "__main__":
    print(json.dumps(probe(), indent=2))
Enter fullscreen mode Exit fullscreen mode

Run that probe three ways before you accept another remote green as evidence of a real fix. The first command shows the laptop habit of standing at the repository root without thinking. The second command reproduces the agent's nested shell, and the third command asks pytest to live with the same facts.

python path_probe.py
cd tests && python ../path_probe.py
python -m pytest -q tests/test_cwd_contract.py
Enter fullscreen mode Exit fullscreen mode

Proposed pytest guard

I also added a tiny pytest that fails closed if cwd is not the repository root. Treat this as a proposed guard, not as a claim that my CI already shipped it. It lives beside the real tests so a drifted agent shell fails before the JSON loader throws a vague error. You can delete it later if your suite has a documented reason to start inside a subdirectory.

# tests/test_cwd_contract.py — proposed guard
from pathlib import Path

import pytest

def repo_root() -> Path:
    here = Path(__file__).resolve()
    for candidate in (here, *here.parents):
        if (candidate / "pyproject.toml").exists() or (candidate / ".git").exists():
            return candidate
    raise AssertionError("could not locate repo root from test file")


def test_cwd_is_repo_root():
    root = repo_root()
    cwd = Path.cwd().resolve()
    if cwd != root:
        pytest.fail(
            f"cwd drifted: cwd={cwd} root={root}. "
            "Load fixtures from Path(__file__), not from Path('data/...')."
        )


def test_events_fixture_exists_next_to_repo_root():
    fixture = repo_root() / "data" / "events.json"
    assert fixture.is_file(), f"missing fixture at {fixture}"
Enter fullscreen mode Exit fullscreen mode

The first test is intentionally strict for suites that already promise to launch from the repository root. Some suites have a reason to run with cwd inside tests/ or inside a temporary directory created by pytest. If that is your contract, delete the first test and keep the second, which uses the file location. I wish I had drawn that line before asking any model to just make the tests pass.

Decision table I keep beside the probe

I keep this table beside the probe so I do not reopen pytest docs for the same confusion. Read cwd as a process fact and rootdir as a collection fact, because they diverge under cd. The safer default is always __file__ or an explicit argument, even when today's cwd makes the relative path look fine. Accidental green is still a bug, just one with a shorter stack trace and a more confident transcript.

Symptom cwd relative to repo Relative Path("data/...") Safer default
Local pytest from root equal accidentally works still use __file__
cd tests && pytest tests/ looks in tests/data/ resolve from __file__
Agent scratch dir /tmp/... missing unless files were copied copy fixtures or pin cwd in a wrapper
pytest --rootdir set may still differ from cwd unchanged do not confuse rootdir with cwd
python -m pytest from root equal accidentally works print both receipts

pytest rootdir is a collection concept, while os.getcwd() is a process concept that loaders actually obey. Mixing them is how I burned the middle of the forty-eight hours on documentation that could not help. If a helper opens files, it should take a Path argument or derive one from __file__, not from ambient cwd. If a command is allowed to cd, the wrapper should cd back, or never cd at all.

Hours 24–48: the fix I would repeat

The loader I would keep uses __file__ so the process can stand anywhere without inventing a new failure mode. Count those parents before you copy the snippet into a module that lives at a different depth. That parents[2] index stays correct only when the module still lives at src/app/events.py in the layout above. The snippet is a reconstructed fix example, not a promise about every repository shape you might own.

# reconstructed fix example
from pathlib import Path
import json

_EVENTS = Path(__file__).resolve().parents[2] / "data" / "events.json"


def load_events() -> list[dict]:
    if not _EVENTS.is_file():
        raise FileNotFoundError(f"events fixture missing: {_EVENTS}")
    return json.loads(_EVENTS.read_text(encoding="utf-8"))
Enter fullscreen mode Exit fullscreen mode

If you move the module, the integer becomes a lie, so I prefer find_repo_root() over a magic parent index. Magic indexes are just relative paths with better posture, and they fail the same way after a rename. Keep the probe in the same commit as the loader so a future nested cd still has a receipt.

The command wrapper I would repeat is even smaller, and it pins cwd before pytest can inherit a nested shell. Put it in scripts/test.sh or a Makefile recipe, but keep the cd in one place instead of in chat. If an agent wants to run one file, pass the path as an argument after the cd, not before it.

#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$repo_root"
python path_probe.py
python -m pytest -q "$@"
Enter fullscreen mode Exit fullscreen mode

Pin cwd in the wrapper, pin fixture paths in the code, and print both receipts in the log. Those three habits would have collapsed my forty-eight hours into one afternoon without requiring a particular model. They require suspicion toward any relative path that looks neat in a tutorial or an agent transcript.

Limitations, and who should not copy this

This approach will annoy you if your test suite intentionally chdirs into temporary directories and restores them in fixtures. It also fights tools that generate isolated workspaces and run pytest inside those copies without the original data/ tree. The probe assumes a marker file such as pyproject.toml or .git exists, which is false for some unpacked artifacts. I am not claiming a performance win, a quota, or hardware details for any remote coding server in this writeup.

Who should skip this

Skip the strict cwd test if you already inject fixture paths through pytest plugins or through explicit CLI arguments. Skip the free remote iteration loop if your files cannot leave your laptop, because a remote shell is still a remote copy. Skip asking a model to fix FileNotFoundError until path_probe.py has printed cwd, __file__, and the resolved fixture path. The model cannot see a directory you never serialized into the transcript, no matter how confident the summary sounds.

What would I repeat tomorrow without hesitation, after throwing away the dead ends from the first night?

  • Print pwd, os.getcwd(), and Path(__file__) in the same log block before blaming pytest.
  • Refuse relative fixture paths that do not start from __file__ or from an explicit argument.
  • Treat a remote green pytest as a hypothesis until the probe JSON matches the laptop.
  • Keep agent transcripts, but demand a cwd receipt before merging the patch they propose.

If you already work in a free remote coding environment, steal the probe and ignore every product sentence around it. I needed forty-eight hours to stop arguing with pytest and start arguing with the working directory instead. Relative paths did not become wrong overnight; I had just never asked which process was standing where. Tomorrow I will print the receipt first, and only then will I let any agent call the suite green.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Top comments (1)

Collapse
 
topstar_ai profile image
Luis Cruz

Your exploration of the cwd issue with pytest highlights a common pitfall in cross-environment testing. I found it insightful how the reliance on relative paths can lead to such frustrating debugging sessions, especially when the local setup diverges from CI/CD environments. One improvement I often consider is enforcing absolute paths or configuring a standard root for test runs to avoid these discrepancies altogether. If you’re contemplating enhancements in the testing setup or need another pair of hands for the next phase, I’d love to collaborate on that. Have you thought about implementing a utility to standardize test environments across different agents?