Skip to content

Testing

Every published component tests its tools end-to-end the same way: run the packed artifact under act run --mcp and drive it with a real MCP client, so what the tests observe is exactly what an agent observes. Both scaffolding templates generate this suite.

test: build
ACT="{{act}}" uv run --project e2e pytest e2e/ -v

That’s the entire justfile side. Everything else — the transport, the capability grants, the “is it actually packed?” check — lives in e2e/conftest.py, where it is reviewable code rather than an ever-growing command line.

e2e/pyproject.toml is a virtual project (package = false), so uv builds an environment from the dependency list without installing your component directory as a distribution:

[project]
requires-python = ">=3.12"
dependencies = ["fastmcp", "pytest", "pytest-asyncio", "pytest-timeout"]
[tool.uv]
package = false
[tool.pytest.ini_options]
asyncio_mode = "auto"
timeout = 300
timeout_method = "thread"

The timeout is deliberately generous: a hung test must fail the job rather than run until CI’s own ceiling.

e2e/conftest.py gives every test a connected MCP client:

from fastmcp import Client
from fastmcp.client.transports import StdioTransport
GRANTS = ["--allow", "wasi:filesystem"] # whatever the component declares
@pytest.fixture
async def client(act_command, wasm_path):
transport = StdioTransport(
command=act_command[0],
args=[*act_command[1:], "run", str(wasm_path), "--mcp", *GRANTS],
keep_alive=False,
log_file=LOG_FILE,
)
async with Client(transport) as connected:
yield connected

Four details that are easy to get wrong:

  • Grants are mandatory in CI. The default policy mode is ask, and a headless run has no prompt channel, so it degrades to deny. Without the --allow flags the failure surfaces as a capability denial that points anywhere except at the missing grant.
  • The client fixture is function-scoped. A component may keep state across calls within one host process, so sharing a process between tests would let them leak into each other. A genuinely stateless component can widen this.
  • act’s audit trail goes to a log file. It writes to stderr unconditionally and is not governed by RUST_LOG, so left alone it floods the pytest output. The template captures it and prints it back only when the run fails.
  • Bound the connect separately. act run --mcp instantiates the component before answering initialize, so a heavy component takes seconds. The template wraps the handshake in its own timeout so a stalled connect reports itself instead of consuming the whole test budget.

cargo build alone produces a .wasm with no act:component section — and an unpacked artifact declares no capability ceiling, so every grant is refused as “outside ceiling”. The wasm_path fixture checks the section rather than just the file:

probe = subprocess.run([*act_command, "inspect", "component-manifest", str(path)],
capture_output=True, text=True)
name = json.loads(probe.stdout or "{}").get("std", {}).get("name", "unknown")
if name in ("", "unknown"):
pytest.fail(f"{path} is built but not packed -- run `just pack`")
async def test_component_exposes_its_tools(client):
"""A component with no tools is almost always a packaging mistake."""
tools = await client.list_tools()
assert len(tools) >= 1
async def test_reverse(client):
result = await client.call_tool("reverse", {"text": "hello"})
assert result.content[0].text == "olleh"

For a single call while you’re still writing the tool, act call is faster than a test run:

Terminal window
act call <wasm> reverse --args '{"text":"hello"}'

Both templates ship a .github/workflows/ci.yml that runs just build → just test → just publish. On pull requests only build and test run; publish fires on a push to main, and the published artifact is then signed with keyless cosign via GitHub OIDC.