Software craft The unwritten rules

Green on my laptop, red in CI

The plugin my test run never loaded.

On this page · 5 sections

I added a pytest plugin to a Python SDK I work on. The SDK already let app developers write scenarios: short async functions that connect to a running development server and check what the app does. The plugin’s job was to let people run those scenarios with plain pytest, next to their ordinary tests.

The change came with tests, and the whole suite passed on my laptop under Python 3.11 and 3.12. CI failed it in two ways I couldn’t reproduce:

  • One of the SDK’s example scenarios, which is only meant to run against a live server and never in CI, tried to reach a development server and got Connection refused.
  • The plugin’s own test failed, and buried in its output was ValueError: Plugin already registered under a different name.

Same commit, same Python versions. The difference was how the package got into the test run. On my laptop, the tests imported the source checkout directly: src/ was on PYTHONPATH, and nothing was installed. CI built a wheel (the .whl file pip installs), installed it, and ran the tests against the installed copy. For most code, that makes no difference. But pytest finds plugins by reading installed package metadata, and a checkout on PYTHONPATH has none. So my laptop and CI were running two different programs.

The same gap exists wherever code is importable without being installed: PYTHONPATH in a shell or .env file, pythonpath = ["src"] in the pytest configuration, or an IDE that adds source roots to the path. An editable install (pip install -e .) doesn’t have this gap; more on that below.

A laptop lane and a CI lane run the same commit. The laptop imports source files, pytest finds no plugins, and two tests pass. CI installs the wheel, whose metadata includes entry_points.txt, pytest loads the plugin, collects an extra scenario, and two tests fail.
The file that matters is entry_points.txt, and only the installed copy has it.

To show exactly what happened, I rebuilt it as a small made-up package called scenariokit, which fails in the same two ways. The example project includes versions before and after the fixes. Every result below comes from running it with pytest 9.1.1 on Python 3.12.

The short version

  • pytest finds installed plugins through entry-point metadata, which only exists once the package is installed. A source checkout on PYTHONPATH has none.
  • So the plugin never loaded on my laptop and did in CI, where a README example scenario was collected as a test and failed with Connection refused.
  • The plugin’s own pytester test loaded it by hand with -p. In CI the entry point registered it a second time, which raised ValueError: Plugin already registered under a different name.
  • The fixes were small: keep the examples out of the default run, and pass -p only when pytest won’t find the plugin by itself.
  • The check that lasts runs the suite against the built wheel in a fresh environment, whenever a change touches entry points, packaging or metadata.

The plugin, and the line pytest reads#

Here’s the project:

text
scenariokit/
├── pyproject.toml # declares the pytest plugin
├── src/scenariokit/
│ ├── __init__.py
│ ├── registry.py # the @scenario decorator
│ ├── context.py # talks to the development server
│ └── pytest_plugin.py # teaches pytest to run scenarios
└── tests/
├── test_registry.py
├── test_pytest_plugin.py # runs pytest inside a test
└── examples/
└── test_greeting_example.py # the README example

A scenario looks like this:

python
@scenario("greeting")
async def greets_new_client(ctx):
assert await ctx.request("HELLO") == "WELCOME"

This one is the README example. It sits under tests/ in a test_ file because that’s how users run scenarios: put them in a test file and point pytest at a running server.

pytest wouldn’t normally run that function. Its name doesn’t start with test_, and pytest doesn’t run async functions by itself. The plugin changes that. pytest is the host: it owns the test run and calls a plugin’s hooks at fixed points, such as “here’s an object from a test file; is it a test?” That’s inversion of control.

But pytest can only call a plugin it knows about. The package tells it through an entry point in pyproject.toml:

toml
[project.entry-points.pytest11]
scenariokit = "scenariokit.pytest_plugin"

pytest11 is the group pytest searches for installed plugins. scenariokit is the plugin’s name, and the value is the module to import.

Here’s the part I had missed: pytest doesn’t read that declaration from pyproject.toml. Building the package copies it into a small metadata file that is installed next to the code, and pytest reads the installed metadata when it starts:

text
site-packages/
├── scenariokit/ # the same .py files as src/scenariokit/
└── scenariokit-0.1.0.dist-info/
├── METADATA
├── RECORD
├── WHEEL
├── entry_points.txt # [pytest11]
│ # scenariokit = scenariokit.pytest_plugin
└── …

A checkout on PYTHONPATH has the .py files but no .dist-info directory. As far as Python’s package metadata is concerned, the package isn’t installed at all. One line shows the difference:

Shell
Terminal window
python -c "from importlib.metadata import entry_points; print(entry_points(group='pytest11'))"

From my source checkout it prints (). With the wheel installed, it prints:

text
(EntryPoint(name='scenariokit', value='scenariokit.pytest_plugin', group='pytest11'),)

You can also see it at the top of any pytest run. When pytest loads a plugin from an entry point, its session header includes it in a line like plugins: scenariokit-0.1.0. The run from my checkout doesn’t list it. That’s a five-second comparison between a CI log and a local run.

So on my laptop, the main test run never loaded the plugin. The one test that needed it loaded it by hand, and that’s where the second failure comes from.

Failure 1: the README example became a test#

When pytest collects a test file, it imports the file and offers each object in it to the loaded plugins. Our plugin claims the scenarios:

python
@pytest.hookimpl(tryfirst=True)
def pytest_pycollect_makeitem(collector, name, obj):
scenario_name = getattr(obj, "_scenario_name", None)
module = getattr(collector, "obj", None)
if scenario_name is None or obj.__module__ != getattr(module, "__name__", None):
return None # not a scenario defined in this file: normal collection
return ScenarioItem.from_parent(
collector, name=f"scenario[{scenario_name}]", fn=obj)

@scenario only tags the function with _scenario_name. The pytest_pycollect_makeitem hook turns tagged functions into test items and returns None for everything else, which leaves normal collection alone. tryfirst=True lets it answer before other async-aware plugins do, and the __module__ check skips scenarios that a test file merely imports. Each item runs its scenario with asyncio.run(), using a context that connects to 127.0.0.1:7357 unless you pass --scenario-server.

Here is the whole path in CI, from startup to the failure:

Numbered sequence in CI: pytest asks installed metadata for pytest11 plugins, receives scenariokit, imports and registers it, imports the example file, asks the plugin whether greets_new_client is a test, gets back a scenario test item, runs it, and the scenario's connection to 127.0.0.1:7357 is refused.
Only step 2’s answer differs between the environments. On my laptop it comes back empty.

On my laptop, pytest still imports tests/examples/test_greeting_example.py, because the file name matches test_*.py. Without the plugin, it finds nothing it recognizes as a test and moves on. Nothing ran, so nothing failed.

In CI, the plugin is loaded before collection starts, so it claims greets_new_client. pytest runs it, and the scenario tries to connect to a development server that CI never started. Here’s that failure, trimmed and reproduced on macOS:

text
FAILED tests/examples/test_greeting_example.py::scenario[greeting]
E ConnectionRefusedError: [Errno 61] Connect call failed ('127.0.0.1', 7357)

(Error 61 is macOS’s number for a refused connection; Linux reports 111.)

The plugin was doing its job. CI was just the first place anything ran that example without a server.

Those examples are documentation, meant for someone who has started a server. So they stay out of this repository’s default run:

toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = ["--ignore=tests/examples"]

Naming the directory still runs them, which is what I wanted. With pytest 9.1.1, pytest tests/examples --scenario-server HOST:PORT collects the scenario despite the ignore option.

Failure 2: the plugin’s own test loaded it twice#

The plugin needs tests of its own, like “does a @scenario function become a test item?” A normal test can’t answer that, so pytest provides the pytester fixture. It writes a throwaway test file and runs a second pytest session on it, inside the same Python process:

python
pytest_plugins = ["pytester"] # enables the pytester fixture
def test_scenario_becomes_a_test_item(pytester):
pytester.makepyfile(test_demo=SCENARIO_FILE)
result = pytester.runpytest("-p", "scenariokit.pytest_plugin",
"--collect-only", "-q")
result.stdout.fnmatch_lines(["*scenario[[]demo[]]*"])

The nested session loads plugins the same way a normal run does. On my laptop there was no metadata to find scenariokit with, so the test had to name it: -p scenariokit.pytest_plugin means “import this module and register it as a plugin.” Without that flag, the nested session wouldn’t know about scenarios at all.

In CI, the nested session loaded it both ways:

Inside the plugin's own test, -p registers the module under the name scenariokit.pytest_plugin, then the installed entry point tries to register the same module object under the name scenariokit, which raises ValueError.

First, -p registered the module under its module name. Then automatic discovery found the entry point and tried to register the same module object under the entry point’s name. Pluggy, pytest’s plugin manager, won’t hold one plugin under two names. Because pytester catches the nested session’s crash, the outer test doesn’t fail with that error. It fails because the expected output never appeared, and the real cause is in the captured output (trimmed):

text
FAILED tests/test_pytest_plugin.py::test_scenario_becomes_a_test_item
E Failed: remains unmatched: '*scenario[[]demo[]]*'
----------------------------- Captured stderr call -----------------------------
ValueError: Plugin already registered under a different name: scenariokit=<module 'scenariokit.pytest_plugin' from '.../site-packages/scenariokit/pytest_plugin.py'>

If a plugin test fails in CI with an unmatched pattern, read the captured stderr before you debug the pattern.

The fix is to pass -p only when pytest won’t find the plugin by itself:

python
from importlib.metadata import entry_points
def plugin_args():
"""Load the plugin explicitly only when pytest won't find it by itself."""
installed = {ep.name for ep in entry_points(group="pytest11")}
return [] if "scenariokit" in installed else ["-p", "scenariokit.pytest_plugin"]

The test then calls pytester.runpytest(*plugin_args(), "--collect-only", "-q"), and the same test works against a checkout or an installed package.

I also checked another way out: name the entry point after the module ("scenariokit.pytest_plugin" = "scenariokit.pytest_plugin"). pytest then sees a plugin with that name is already registered and skips the second one, so -p stops colliding. But the entry-point name is also what users type to turn a plugin off (-p no:scenariokit), so I’d rather keep the short name and fix the test.

Run what CI runs, before CI does#

Both fixes were small once I could see the failures. The expensive part was not being able to see them on my laptop. The fix that lasts is a check that tests what CI tests (the built wheel) in a fresh environment that has only the wheel and pytest installed. With build installed:

Shell
Terminal window
python -m build --wheel # the artifact CI installs
python -m venv --clear /tmp/wheel-check # a fresh, empty environment
/tmp/wheel-check/bin/pip install pytest dist/scenariokit-0.1.0-py3-none-any.whl
/tmp/wheel-check/bin/python -c "import scenariokit; print(scenariokit.__file__)"
/tmp/wheel-check/bin/python -m pytest # the suite, against the installed copy

--clear matters on the second run. Without it, venv reuses the old environment, and pip keeps the previously installed wheel if the version number hasn’t changed. The check would quietly test yesterday’s build.

The fourth command is there because a passing run only means something if it used the installed copy. It should print a path inside /tmp/wheel-check/lib/.../site-packages. That isn’t automatic: python -m pytest adds the current directory to sys.path. With the package under src/, it isn’t importable from the project root, so the tests get the installed copy. If the package sat at the top level of the repository, the same commands would quietly test the checkout again. The same happens if your pytest configuration sets pythonpath = ["src"]; add -o pythonpath= to the last command to switch it off for this run.

Here’s what the check reports for each version of the example, next to the run I used to trust:

Version of the suiteFrom source (PYTHONPATH=src pytest)Installed wheel, fresh environment
Before the fixes2 passed2 failed, 1 passed
With only the --ignore change2 passed1 failed (pattern unmatched; ValueError in captured stderr), 1 passed
Both fixes2 passed2 passed

The left column never changes. That’s the whole problem in one table: the run I relied on couldn’t tell these three versions apart.

An editable install (pip install -e .) also writes the entry-point metadata, and it reproduced both failures here. For day-to-day runs, I’d use one instead of bare PYTHONPATH. It still runs the files in your checkout, though, so it can’t tell you whether the wheel is missing something. The wheel check can.

When I run the wheel check#

The wheel check is slower than a normal test run, so I don’t run it on every change. I run it when a change touches how the package presents itself to other programs:

  • anything under [project.entry-points.*] or [project.scripts]: pytest plugins, console commands, other tools’ plugin groups;
  • tests that start pytest themselves, or code that reads package metadata through importlib.metadata;
  • what goes into the wheel (package data, include and exclude rules, the build backend), which only the wheel check sees;
  • any CI failure I can’t reproduce locally. That was the signal I had here, and it’s the cheapest one to act on.

Neither CI failure was flaky, and neither was a CI quirk. Both were accurate reports of what the suite does once the package is really installed. The environment that was wrong was my laptop: it was testing a package that doesn’t exist outside my checkout. So when a change touches any of those, I build the wheel, install it into a clean environment, and run the suite there before CI does.

Share this article

Related posts

3 items
  • Oct 2, 2026 16 min read Software craft

    How many concepts is too many

    Framework debates trade concept counts that measure different things. Count what the smallest real program makes its user write, in separate columns and per reader, find the rules the count can't see by misspelling them, and keep an extra concept only when it moves a check earlier or gives users a seam they can see.

    The unwritten rulespythonapi-designframeworks
  • Aug 31, 2026 11 min read Software craft

    Keeping optional Python imports optional

    A test that checks importing a library leaves its optional dependency unloaded, and proves it got far enough to notice.

    The unwritten rulespythontesting
  • Oct 21, 2026 12 min read Software craft

    The one-sentence API design test

    Six AI agents each designed one extension to my framework, and each design held up on its own. Side by side they disagreed about names every user touches. One question sorted them out, and writing down the rules my existing code already followed made the answer countable.

    The unwritten rulesapi-designai-agentspython
  • Software craft

    How many concepts is too many

    Framework debates trade concept counts that measure different things. Count what the smallest real program makes its user write, in separate columns and per reader, find the rules the count can't see by misspelling them, and keep an extra concept only when it moves a check earlier or gives users a seam they can see.

    pythonapi-design
    Oct 2026
  • Software craft

    Keeping optional Python imports optional

    A test that checks importing a library leaves its optional dependency unloaded, and proves it got far enough to notice.

    pythontesting
    Aug 2026
  • Software craft

    The one-sentence API design test

    Six AI agents each designed one extension to my framework, and each design held up on its own. Side by side they disagreed about names every user touches. One question sorted them out, and writing down the rules my existing code already followed made the answer countable.

    api-designai-agents
    Oct 2026

Comments