Skip to content

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:

[tool.pytest.ini_options]
asyncio_mode = "auto"

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:

svc = await session.service(IMyService)

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_info so the next handler in the pipeline sees it.
  • Never write to the source file. Return changed and code; the built-in SaveFormatFileHandler performs the actual disk write when payload.save is True. A content-transformation handler must never write to the source file itself, regardless of the save flag.

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=100 actually 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 env differs from the calling environment).

These paths are covered by the end-to-end suite in tests/e2e/er/.