Testing Action Handlers¶
FineCode ships a testing harness in finecode_extension_runner.testing that lets
extension authors write fast, integration-style tests for their handlers. The
harness runs the real Extension Runner orchestration — DI, lifecycle hooks,
concurrent/sequential execution, partial-result scheduler — inside the test
process. No Workspace Manager, no ER subprocess, no virtualenvs are needed.
How it works¶
handler_test_session builds a RunnerContext from a plain Python dict, wires
the real DI registry, and then yields a Session object. Session.run_action()
calls the same run_action function that the IPC path uses. Everything between
the test assertion and the handler's return statement is production code.
What stays out of scope:
- WM project discovery and config merging (tests pass an explicit action dict).
- ER subprocess and IPC (covered by
tests/e2e/er/). - Real virtualenvs (handlers and their dependencies must be importable in the Python environment that runs the tests).
Environment setup¶
Add the following to your extension package's pyproject.toml:
[dependency-groups]
dev = [
"pytest>=7",
"pytest-asyncio>=0.23",
"finecode_extension_runner~=0.4.0a0",
]
Enable asyncio mode in pyproject.toml or pytest.ini:
Handlers and their runtime dependencies must be importable in the same
environment. If your extension already uses finecode prepare-envs the handler
package is already installed in the dev env, so running pytest from there is
all that is needed.
Basic test¶
# tests/test_format_handler.py
from pathlib import Path
from fine_python_lang.format_python_file_action import (
FormatPythonFileAction,
FormatPythonFileRunPayload,
)
from finecode_extension_api.interfaces.ifileeditor import IFileEditor
from finecode_extension_runner.testing import (
handler_test_session,
InMemoryFileEditor,
)
async def test_format_handler_changes_file(tmp_path: Path) -> None:
file_path = tmp_path / "foo.py"
file_editor = InMemoryFileEditor()
file_editor.seed(file_path, "x =1\n")
async with handler_test_session(
project_dir=tmp_path,
actions={
"format_python_file": {
"source": (
"finecode_extension_api.actions.code_quality"
".format_python_file_action.FormatPythonFileAction"
),
"handlers": [
{
"name": "my_formatter",
"source": "my_extension.MyFormatHandler",
"config": {"line_length": 88},
}
],
}
},
service_overrides={IFileEditor: file_editor},
) as session:
result = await session.run_action(
"format_python_file",
FormatPythonFileRunPayload(file_path=file_path, save=True),
)
assert result is not None
assert result.changed
assert file_editor.contents(file_path) == "x = 1\n"
InMemoryFileEditor intercepts all file I/O. file_editor.seed(path, text)
provides the input; after the handler runs file_editor.contents(path) gives
the mutated content without touching the real filesystem.
session.file_editor is a shortcut to the same InMemoryFileEditor instance if
it was passed as a service_overrides entry:
session.file_editor.seed(file_path, "x =1\n")
result = await session.run_action(...)
assert session.file_editor.contents(file_path) == "x = 1\n"
Asserting that no file was written¶
InMemoryFileEditor.writes records every save_file call:
assert session.file_editor.writes == [] # nothing was saved
assert len(session.file_editor.writes) == 1 # exactly one file saved
path, content = session.file_editor.writes[0]
assert path == file_path.resolve()
Testing a linter handler¶
from finecode_extension_api.actions.code_quality.lint_files_action import (
LintFilesAction,
LintFilesRunPayload,
LintFilesRunResult,
)
async def test_linter_reports_diagnostic(tmp_path: Path) -> None:
bad_file = tmp_path / "bad.py"
file_editor = InMemoryFileEditor()
file_editor.seed(bad_file, "import os\n") # unused import
async with handler_test_session(
project_dir=tmp_path,
actions={
"lint_files": {
"source": "finecode_extension_api.actions.LintFilesAction",
"handlers": [
{"name": "my_linter", "source": "my_extension.MyLintHandler"},
],
}
},
service_overrides={IFileEditor: file_editor},
) as session:
result = await session.run_action(
"lint_files",
LintFilesRunPayload(file_paths=[bad_file]),
)
assert result is not None
assert len(result.diagnostics) == 1
assert result.diagnostics[0].message.startswith("unused import")
Suppressing log output¶
By default the real loguru logger is wired in. To silence it during tests,
override ILogger with NoOpLogger:
from finecode_extension_api.interfaces.ilogger import ILogger
from finecode_extension_runner.testing import NoOpLogger
async with handler_test_session(
...,
service_overrides={
IFileEditor: file_editor,
ILogger: NoOpLogger(),
},
) as session:
...
One-shot shim for single-handler tests¶
run_handler wraps a single handler in a minimal session, runs it, and tears
down. Use it when a full session is more boilerplate than value:
from finecode_extension_runner.testing import run_handler
result = await run_handler(
MyFormatHandler,
FormatPythonFileRunPayload(file_path=path, save=False),
action_cls=FormatPythonFileAction,
project_dir=tmp_path,
service_overrides={IFileEditor: file_editor},
)
assert result.changed
Inspecting WAL events¶
When you need to assert that a specific execution lifecycle event was emitted, switch to an in-memory WAL writer:
from finecode_extension_runner.testing import InMemoryWalWriter
from finecode_extension_runner.er_wal import ErWalEventType
async with handler_test_session(..., wal="memory") as session:
await session.run_action(...)
events_by_type = {e.event_type for e in session.wal_events}
assert ErWalEventType.RUN_COMPLETED in events_by_type
The default wal="null" silently drops all WAL writes, which is correct for
most tests that only care about the action result.
Handler configuration¶
Pass per-handler configuration through handler_configs:
async with handler_test_session(
project_dir=tmp_path,
actions={
"format_python_file": {
"source": "...",
"handlers": [{"name": "my_formatter", "source": "my_extension.MyFormatHandler"}],
}
},
handler_configs={"my_formatter": {"line_length": 120}},
service_overrides={IFileEditor: file_editor},
) as session:
...
This is equivalent to the [[tool.finecode.action_handler]] config block in
pyproject.toml.
Using additional services¶
If your handler depends on a service other than IFileEditor or ILogger, stub
it out via service_overrides:
from my_extension.services import IMyService
class _StubMyService:
async def fetch(self, key: str) -> str:
return "stub_value"
async with handler_test_session(
...,
service_overrides={IMyService: _StubMyService()},
) as session:
...
You can also look up an already-instantiated service from the session after bootstrap completes:
Testing multi-handler actions¶
Session.run_handlers() mirrors run_handlers_raw and lets you run a subset of
handlers in isolation — useful for testing how one handler in a chain processes
the result produced by a previous handler:
first_result = (await session.run_handlers(
"format_python_file",
handler_names=["formatter_a"],
payload={"file_path": str(file_path), "save": False},
)).result
second_result = await session.run_handlers(
"format_python_file",
handler_names=["formatter_b"],
payload={"file_path": str(file_path), "save": False},
previous_result=first_result,
)
Test requirements: three layers¶
Every handler for an action must honour three distinct sets of requirements. Each has its own base class and its own failure mode.
Action contract¶
An action defines a behavioral contract — not just types, but semantics.
For FormatFileAction that contract includes: result.changed is True iff
the file needed changes, result.code is empty when nothing changed, and
save=False means no disk write. Every handler must honour this regardless of
which underlying tool it wraps.
These invariants are expressed in the base class FormatFileContractTests
co-located with the action definition:
from fine_format.format_file_contract import (
FormatFileContractTests,
)
class TestMyHandlerContract(FormatFileContractTests):
handler_cls = MyFormatHandler
unformatted_snippet = "x=1\n"
_subject_filename = "subject.py" # override if the tool is extension-sensitive
Handler implementation contract¶
The action contract is observable from the outside. There is a second, internal contract: how a handler must participate in the FineCode pipeline.
- Read from context, not disk. Input content lives in
run_context.file_info. A prior handler may have already modified the content; that updated content is in the context, not yet on disk. A handler that reads the file directly from disk silently discards all prior handlers' work. - Update the context. Write the formatted content back to
run_context.file_infoso the next handler in the pipeline sees it. - Never write to the source file. Return
changedandcode; the built-inSaveFormatFileHandlerperforms the actual disk write whenpayload.saveisTrue. A content-transformation handler must never write to the source file itself, regardless of thesaveflag.
These invariants are expressed in FormatFileHandlerTests, which extends
FormatFileContractTests:
from fine_format.format_file_contract import (
FormatFileHandlerTests,
)
class TestMyHandler(FormatFileHandlerTests):
handler_cls = MyFormatHandler
unformatted_snippet = "x=1\n"
_subject_filename = "subject.py"
FormatFileHandlerTests already inherits all action contract tests, so
subclassing it alone is sufficient for content-transformation handlers.
Handler-specific requirements¶
These cover what the contract base classes structurally cannot — the behaviour of the specific tool the handler wraps:
- Exact output. Black and autopep8 format the same ugly code differently. The expected string is tool-specific and belongs in the handler's own tests.
- Configuration behaviour. Whether
line_length=100actually changes where the tool wraps is a handler detail, not part of any contract. - Tool-specific error scenarios. Binary not found, corrupt config file, exit code 2 vs 1 — these are handler concerns.
- Tool edge cases. Inputs the tool is known to mishandle or produce surprising output for.
Handler-specific tests live in the same subclass alongside the inherited contract tests.
The boundary rule¶
| Assertion | Layer |
|---|---|
| "result is consistent with the action contract" | Action contract (FormatFileContractTests) |
| "handler reads from context / never writes to source file" | Implementation contract (FormatFileHandlerTests) |
| "this handler produced this specific output" | Handler-specific |
| "this handler handles this tool-specific situation" | Handler-specific |
In practice most handlers will have few contract tests and more handler-specific tests — tool behaviour is where the real complexity lives.
What is not tested¶
The harness deliberately excludes the IPC layer. Tests using
handler_test_session do not cover:
- JSON-RPC message serialisation and deserialisation.
- WM config merging, project discovery, or environment selection.
- Cross-environment handler dispatch (when a handler's
envdiffers from the calling environment).
These paths are covered by the end-to-end suite in tests/e2e/er/.