Testing

Quick Start

uv sync --extra dev
uv run pytest tests/unit

The unit tier is the one you run while working — roughly 6,600 tests in about half a minute. pytest with no path runs everything under tests/, which adds the integration tier and the in-process conformance and CI-metadata checks.

Running Tests

# The tier you edit against
pytest tests/unit

# One file, or one test
pytest tests/unit/test_registry_cache.py
pytest tests/unit/test_registry_cache.py::TestRegistryCache::test_set_and_get

# Everything CI runs on a PR
pytest --ignore=tests/integration
pytest tests/integration/

# Coverage, when you actually want it
pytest tests/unit tests/integration --cov=mcp_hangar --cov-report=term-missing

Coverage is not on by default. It costs about 18 seconds and 6,600 lines of output on every run, including a run of a single file, so the jobs that read a report ask for it explicitly.

Markers

MarkerDescription
benchmarkPerformance benchmarks (pytest-benchmark)
securitySecurity regression tests — the category is the marker, not a directory
liveBlack-box verification against a running gateway; opt-in
t0Live tier 0 — single process, stub backend
t1Live tier 1 — multi-backend / groups, needs compose
t2Live tier 2 — auth / IdP, needs Keycloak

That is the whole list, and all six are registered in pyproject.toml. pytest does not run with --strict-markers, so -m something-else selects nothing and exits green — a passing run of zero tests. Check the collected count when a marker filter returns suspiciously fast.

There are no opt-in flags

A test that only runs behind a flag nobody passes does not run. --run-containers and --run-slow, the testcontainers fixtures behind them, and the containers pip extra were all deleted for that reason: the tiers had been dead for months and nothing noticed. Anything that needs a real runtime belongs in tests/live (nightly) or in a lab you drive by hand.

Test Tiers

PathWhat it isHow it runs
tests/unit/The bulk of the suite, in-processevery PR
tests/integration/Multiple components together, still in-processevery PR, its own job
tests/conformance/The gateway against the MCP specevery PR
tests/ci/Assertions about the repo's own workflowsevery PR
tests/benchmark/pytest-benchmark timingsevery PR (collected by the bare pytest run)
tests/live/Black-box against a running gatewaynightly, opt-in
tests/acceptance/A shell script against a cluster you ownby hand

Live verification

tests/live is gated on an environment variable, not a flag, and skips entirely without it:

MCP_HANGAR_LIVE_VERIFY=1 pytest tests/live -m t0 --timeout=180 -ra

Tier t0 needs only a running gateway. t1 needs a compose stack, t2 needs Keycloak. See tests/live/README.md in the core repo for what each tier assumes.

Acceptance

tests/acceptance/ha_two_gateways.sh is a shell script, run by hand against a cluster you own — it kills a pod. It is deliberately not pytest: it tests the deployment, which is how it found that a shipped image carried no PostgreSQL driver. Apply the manifests beside it (ha-postgres.yaml, ha-gateway.yaml) first.

Property-Based Testing

MCP Hangar uses Hypothesis for property-based testing.

pytest tests/unit/observability/test_property_based.py -v

# Reproduce a specific run
pytest tests/unit/observability/test_property_based.py --hypothesis-seed=12345

Example Property Test

from hypothesis import given, strategies as st

@given(
    mcp_server_name=st.text(min_size=1, max_size=50),
    tool_name=st.text(min_size=1, max_size=50),
)
def test_adapter_accepts_any_strings(mcp_server_name, tool_name):
    """Adapter accepts any valid string inputs."""
    adapter = NullObservabilityAdapter()
    span = adapter.start_tool_span(mcp_server_name, tool_name, {})
    assert isinstance(span, NullSpanHandle)

Manual Testing

Mock MCP server

tests/mock_provider.py implements the JSON-RPC MCP protocol and is what the suite points a subprocess MCP server at:

# config.yaml
mcp_servers:
  math:
    mode: subprocess
    command: [python, tests/mock_provider.py]
mcp-hangar serve --http

Drive it directly to check a handshake:

echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | python tests/mock_provider.py

Test via Python

from mcp_hangar.domain.model import McpServer

mcp_server = McpServer(
    mcp_server_id="test",
    mode="subprocess",
    command=["python", "tests/mock_provider.py"]
)

mcp_server.ensure_ready()

result = mcp_server.invoke_tool("add", {"a": 5, "b": 3})
print(result)  # {"result": 8}

mcp_server.shutdown()

Common Issues

MCP server won't start

echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | python tests/mock_provider.py

Permission denied (container)

mcp_servers:
  memory:
    mode: container
    read_only: false
    volumes:
      - "./data:/app/data:rw"

Tests hang

pytest tests/ -v --timeout=60