Skip to content

test: per-tool wire contract, real-lifespan assembly test and a 98% coverage gate - #87

Merged
vish288 merged 4 commits into
mainfrom
harness-gitlab
Sep 25, 2026
Merged

vish288 merged 4 commits into
mainfrom
harness-gitlab

Conversation

@vish288

@vish288 vish288 commented Sep 24, 2026

Copy link
Copy Markdown
Owner

What

Makes the suite prove tool behaviour through the real MCP server, then locks that in CI. No changes under src/.

  • tests/unit/test_tool_contract.py: one row per @mcp.tool (83), derived by reading each tool body and the GitLabClient method it calls. test_request_shape asserts method, path, query and JSON body of route.calls.last.request; test_forced_failure answers 404 on the same route and asserts the _err key set, which is what finally executes every tool's except arm. test_every_tool_has_a_row keeps the table equal to list_tools().
  • tests/unit/test_server_assembly.py: boots through the real lifespan (servers/gitlab.py:31-41, previously uncovered) and compares tool/resource/prompt counts against the decorator counts read from src/ at test time; reads every resource.
  • tests/unit/test_exceptions.py: _err table, exact key set per exception type.
  • tests/conftest.py: tool_client / readonly_client lifted from test_tools.py so all tool-level tests share them; lifespan restore moved into finally; client fixture that closes its httpx.AsyncClient.
  • pyproject.toml: [tool.coverage.run] source = ["src"] so the number is the package's number, not tests/ mixed in.
  • .github/workflows/tests.yml: --cov-fail-under=98.
  • .gitignore: .internal/.

Why

Before this, exactly one test asserted an outgoing request and 75 of 83 tools never had their except arm run. A refactor that mangled a POST body or dropped a kwarg passed green. The error-contract change (#75, #76) is only safe once this exists.

Numbers

before after
tests 228 405
src/ coverage 88% 99% (1537 stmts, 19 missed)
servers/gitlab.py 84% 99%
outgoing-request assertions 1 84

Gate proof, run locally with --cov-fail-under=98: deselecting test_forced_failure drops src/ to 89.13% and pytest exits non-zero with FAIL Required test coverage of 98% not reached. Deselecting a single row leaves 99%; the margin at 98 is about 12 lines.

Housekeeping absorbed from #80

The tests/ items only: redundant @pytest.mark.asyncio in test_client.py, _make_mcp annotated -> FastMCP while returning a tuple, mcp._lifespan restore that never ran if Client(mcp) raised, unclosed GitLabClient in test_client.py and conftest.py. The src/ items in #80 are untouched here.

Also found on the way: the config / client / mock_api fixtures in conftest.py had no callers (coverage showed their bodies unexecuted). client and mock_api now back test_client.py; config is gone.

.internal/ holds the ticket tracker and working spec for the test-confidence
initiative. They are kept out of history until the tracked issues are filed
and fixed, at which point the spec moves to docs/specs/.
tool_client/readonly_client move from test_tools.py:22-59 into
tests/conftest.py so the new contract and assembly tests can use them. The
move fixes three items from #80 on the way:

- _make_mcp was annotated -> FastMCP but returned a 2-tuple; it is now a
  context manager and the fixtures are two lines each.
- mcp._lifespan was restored after the with-block, so a failure inside
  Client(mcp) left the mock lifespan in place for every later test. The
  restore now sits in a finally.
- test_client.py:16-17 built a GitLabClient per test and never closed it,
  leaking an httpx.AsyncClient each time. TestRequest now uses a conftest
  client fixture that closes on teardown, and the respx router comes from
  mock_api; the 14 @pytest.mark.asyncio markers were redundant under
  asyncio_mode = "auto" and are gone.

The old config/client/mock_api fixtures in conftest.py:16-28 had no callers
(coverage showed their bodies unexecuted); client and mock_api are
reintroduced with real users, config is folded into client.
… tool

Before this the suite asserted an outgoing request exactly once
(test_tools.py, the page param on list_branches) and never executed the
except arm of 75 of the 83 tools (servers/gitlab.py was 84% covered, the
missing 168 lines being the `except Exception as e: return _err(e)` pairs).
A change that mangled a POST body or dropped a kwarg passed green.

test_tool_contract.py holds one row per @mcp.tool, derived by reading each
tool body and the GitLabClient method it calls: (name, args, method, path,
query, json body). test_request_shape mocks the route and checks all four
against route.calls.last.request; test_forced_failure answers 404 on the
same route and checks the _err key set, which is what runs the except arm.
test_every_tool_has_a_row keeps the table equal to list_tools().

test_server_assembly.py boots through the real lifespan (gitlab.py:31-41,
previously uncovered) and compares list_tools/resources/prompts against the
decorator counts read from src/ at test time.

test_exceptions.py gains the _err table: exact key set per exception type,
so dropping body or hint from an envelope fails a test rather than a user.

tests: 228 -> 405. src/ coverage: 88% -> 99%; servers/gitlab.py 84% -> 99%.
The coverage report mixed tests/ into the total (tests/test_links.py sat at
80% because its network cases are deselected), so the number was not the
number of the package. source = ["src"] in pyproject.toml scopes it.

Measured src/ after the contract tests: 98.76% (1537 statements, 19
missed). The gate is the floor of that. Proven locally: deselecting
test_forced_failure drops src/ to 89.13% and pytest exits non-zero with
"FAIL Required test coverage of 98% not reached".

@vish288 vish288 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: would approve — self-approval blocked by GitHub

Caught before merge: the branch was cut before the 0.10.2 release, so its pyproject/CHANGELOG/server.json/uv.lock diff reverted that bump (0.10.2 → 0.10.1). Rebased onto current main; the PR now touches only tests/, tests.yml, .gitignore and the coverage config. The worker had noticed the version mismatch but read the cause backwards.

Correctness. All 83 rows verified against list_tools() at test time (test_every_tool_has_a_row), so the table cannot drift from the registry. test_request_shape asserts method, raw path, query and decoded JSON body — the assertion this suite had exactly once before. test_forced_failure executes the except arm on every tool: servers/gitlab.py 84% → 99%.

Gate. --cov-fail-under=98 with source=["src"] so tests/ no longer inflates the number. Proven: deselecting test_forced_failure → 89%, build fails. Margin ~12 lines — tight but honest.

Verification. 405 passed, ruff and format clean, build ok, CI green on 3.10–3.13 after rebase.

@vish288
vish288 merged commit 68be097 into main Sep 25, 2026
10 checks passed
@vish288
vish288 deleted the harness-gitlab branch September 25, 2026 16:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant