rust-plugin/skills/mockito-http-mocking/SKILL.md
mockito HTTP mocking for Rust integration tests: expectation semantics, mock matching order, and taming polling clients against instant mock servers. Use when mocking an HTTP API in Rust tests, when a wait-until-matched loop hangs, or when tests pass alone but hang under concurrent load.
npx skillsauth add laurigates/claude-plugins mockito-http-mockingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
mockito spins up a real local HTTP server per test and matches incoming requests against declared mocks. Two of its semantics are routinely misread, and one interaction with polling clients produces hangs that only appear under concurrent load. All three were debugged live against a teloxide long-polling bot (truenas-ai-sentinel PR #12); the patterns apply to any Rust client that re-requests an endpoint in a loop.
| Use this skill when... | Use sibling skill instead when... |
|---|---|
| Mocking an HTTP API (Bot APIs, REST backends) in integration tests | Running/parallelizing the tests -- use cargo-nextest |
| A matched() wait loop hangs or a mock "never gets hit" | Authoring async test code in general -- use rust-development |
| Tests pass alone but hang/flake when the suite runs concurrently | Measuring coverage of the tests -- use cargo-llvm-cov |
| Layering specific and catch-all mocks on one path | |
matched() means "hit count EQUALS the expectation" — not "was hit"The default expectation is exactly 1. A mock hit twice makes
matched() return false again, so a wait-until-matched loop that raced
past one hit silently un-matches:
// Wrong for anything a client may hit repeatedly: matched() flips back
// to false on the second hit.
let m = server.mock("POST", "/x").with_body("ok").create_async().await;
// Right: "at least once" is what a readiness wait actually means.
let m = server.mock("POST", "/x").with_body("ok")
.expect_at_least(1).create_async().await;
while !m.matched_async().await { tokio::time::sleep(Duration::from_millis(50)).await; }
Reserve exact expect(n) / expect(0) for the final assert_async()
after the client has been stopped — expect(0) plus a settle delay is the
correct shape for "this endpoint must never be called".
When several mocks cover the same method + path, mockito uses the first created mock whose matchers accept the request. Create specific body-matched mocks before the catch-all:
// Specific first: requests carrying {"offset":2} get the empty response...
let drained = server.mock("POST", "/api/poll")
.match_body(Matcher::PartialJsonString(r#"{"offset":2}"#.into()))
.with_body(r#"{"ok":true,"result":[]}"#)
.create_async().await;
// ...everything else falls through to the catch-all batch.
let batch = server.mock("POST", "/api/poll")
.with_body(one_update_json())
.create_async().await;
Matcher::PartialJsonString compares values, not just keys —
{"offset":0,...} does not match a {"offset":2} partial. Verify with a
raw reqwest probe when in doubt; guessing the matching semantics from a
failing dispatcher test conflates several failure modes.
Real long polling holds the HTTP connection server-side. mockito responds
instantly, so a long-poll client (getUpdates-style loops, SQS-style
receive loops) degenerates into a hot request loop at ~100% CPU. Two
consequences:
cargo nextest run --test x <one_test> passes in milliseconds, the full
run gets killed after minutes with SIGTERM, and ps shows the test
binary spinning at 100% CPU).Respond 500 on the post-consumption poll request. Well-behaved clients (teloxide, most SDKs) back off exponentially on errors, turning the hot loop into a gentle retry — while the mock still counts as matched, which is usually the "update was consumed" signal the test waits on:
let drained = server.mock("POST", "/api/poll")
.match_body(Matcher::PartialJsonString(r#"{"offset":2}"#.into()))
.with_status(500)
.with_body(r#"{"ok":false,"error_code":500,"description":"drained (test backoff)"}"#)
.expect_at_least(1)
.create_async().await;
task.abort() instead of graceful shutdown in testsA test does not need to prove graceful shutdown on every run. After the awaited assertion signal fires, cancel the client task outright:
wait_until_matched(&send).await;
task.abort();
let _ = task.await; // JoinError::is_cancelled — expected
send.assert_async().await;
Keep one dedicated test for graceful shutdown if the shutdown path itself is under test; don't pay its flake risk in every dispatcher test.
Polling sends {"offset":0,"timeout":N} on its first
request — the offset field is present, not absent. Body matchers and
"no offset yet" assumptions must account for it. Method paths are
PascalCase: /bot<token>/GetUpdates, /GetMe, /SendMessage.GetMe at startup — mock it or the
dispatcher never polls.| Symptom | Likely cause |
|---|---|
| Wait loop times out though the endpoint was clearly called | matched() vs expectation mismatch — use expect_at_least(1) |
| Specific mock never matches; catch-all serves everything | Catch-all was created first — reorder |
| Test passes alone, hangs in the full suite; binary at 100% CPU | Hot poll loop — apply Fix A + Fix B |
| Client errors repeatedly right after startup | A required bootstrap call (e.g. GetMe) has no mock |
When the failure resists reasoning, probe empirically: point the client at
a raw tokio::net::TcpListener that logs request lines and bodies — five
minutes of captured traffic beats an hour of guessing what the client
sends.
development
Debug HTTP APIs: trace requests, inspect headers. Use when a request fails: check status first.
documentation
Render architecture diagrams from text sources. Use when documenting system topology.
tools
Inspect JSON payloads and extract nested fields. Use when parsing API responses.
tools
--- name: no-description allowed-tools: Read --- # No Description This skill has no description and must be dropped with a warning.