Smoke: Black-Box Route Testing the Router Gates Itself
Every unit test was green. The page returned 502 anyway.
The route was a treasure generator. Its store had a thorough test suite, all passing, because the test built the store the way the test knew to build it – with the database pool wired in. Production built it differently: a copy-paste in the route setup left the pool out, the store carried a nil handle, and the first query dereferenced nil. The handler panicked, the connection dropped, the reverse proxy turned that into a 502. No test caught it, because no test exercised the wired route against a running server. The tests checked the parts. Nothing checked that the assembled thing served.
That class of bug – wiring, dependency injection, a migration that didn’t run, a config value that’s wrong in one environment, a reverse-proxy rule – lives in the gap between “the handler is correct” and “the deployment serves it.” Unit tests with fakes can’t see it by construction. The fix is dumb and old: hit every URL on a real instance and check it doesn’t 5xx. smoke is that check, plus the one piece that makes it stick.
What’s already out there, and why none of it fit
Black-box HTTP testing is not a new idea, so the first question is why write another one.
Schemathesis
and Dredd
are property and contract testers driven by an OpenAPI spec. They’re good. They also presuppose a spec, and a Go server built on the standard library’s net/http doesn’t have one. Generating an OpenAPI document just to feed a tester is more work than the tester, and it creates a second source of truth that drifts from the routes the moment someone adds a handler.
k6
is load testing – the wrong axis. Playwright
drives a real browser, which is heavier than the job needs and pulls in a JavaScript toolchain for what is fundamentally “send an HTTP request, look at the status.” Hurl
is the closest fit: declarative .hurl files, real assertions, CI-native, a single binary. It’s a fine way to write request-response checks. But it can’t answer the question that actually matters over time: did someone add a route and forget to test it? Nothing in the Hurl model knows what routes exist, so nothing can enforce that they’re all covered.
That last gap is the whole point. Hitting a list of URLs is trivial. Keeping the list honest as the codebase changes is the part that rots, and it’s the part no off-the-shelf tool can do for you, because it requires knowing how your server registers routes.
The shape
smoke is two pieces in one module. The library, smoke, is imported by the server and records routes. The CLI, smolder
, probes a running instance and runs the coverage gate. The name is the obvious one: smolder is the sustained low heat; smoke is what you watch for.
You register routes through a thin wrapper around http.ServeMux. Every registration records a spec and then delegates to the real mux:
mux := smoke.NewMux() // wraps *http.ServeMux, records a spec per route
mux.HandleFunc("GET /health", health) // covered: no params
mux.HandleFunc("GET /widgets/{id}", show, smoke.Example("id", "42")) // covered: example param
mux.HandleFunc("GET /account", dashboard, smoke.AuthRequired()) // probed only with a session
mux.HandleFunc("POST /widgets", create, smoke.Write()) // mutating; skipped by default
mux.HandleFunc("GET /admin/", adminUI, smoke.Status(403)) // asserts an exact status
http.ListenAndServe(":8080", mux) // *smoke.Mux is an http.Handler
The wrapper is necessary because net/http’s ServeMux exposes no way to enumerate what’s registered. There is no mux.Routes(). If you want to know the route set, you have to capture it at registration time, which means registration is the only honest source. The existing HandleFunc/Handle calls keep their signatures and pick up an optional trailing list of spec options – pass none and the call compiles exactly as it did before. So converting a server is mechanical: swap the mux type, and every registration still builds, gaining a spec only where you choose to annotate it.
The recorded specs serialize to a manifest, a stable-sorted JSON document that is the contract between the two halves. The server exposes it at an endpoint (gated to non-production – it enumerates your routes, which is a reconnaissance gift you don’t hand out), and a copy is committed to the repo. smolder run probes every route in it; smolder gate checks coverage.
smolder run --base https://preview.example.com [--target preview|live] [--include-writes --cookie '…']
smolder gate --manifest routes.json --mode fail
The router gate is the part that matters
Here is the feature that justified building this instead of adopting something.
A route is “covered” when it carries what it needs to be probed: an example for each path parameter, or an explicit skip with a reason. smolder gate --mode fail exits non-zero when any registered route is uncovered. Wire that into CI and the consequence is direct: you cannot add a route without also declaring how to smoke it, or the build breaks.
This inverts the usual relationship between code and tests. Normally tests are something you remember to write. Here, registration is the trigger – the same line that adds the route is the line the gate inspects, so a new endpoint with no smoke spec is a failing build the same day it’s written, not a coverage gap discovered months later. The committed manifest makes it visible in review, too: a pull request that adds three routes shows three new manifest entries, and a regeneration step fails CI if the committed copy is stale, the same way a generated-code drift check works.
The gate runs in two strictnesses. A pre-push hook runs it in warn mode – it prints the uncovered routes as a prompt and never blocks. CI runs it in fail mode. The point of the warn mode is social, not mechanical: it nudges at commit time so the hard gate in CI is never a surprise.
The coverage question is one no spec-driven tool can answer for a net/http server, because the spec is the registration, and only a wrapper that sits in the registration path can see it. That’s the line off-the-shelf tools can’t cross.
Authentication without a backdoor
Most interesting routes need a logged-in user, so a black-box tester that can only make anonymous requests covers the boring half of the surface. The tempting shortcut is a test-only auth bypass compiled into the server – an env-gated header that mints a session. It works, and it’s a login backdoor sitting in your production binary forever. I didn’t want one.
The server I built this against is an OIDC relying party, and its session cookie value is the access-token JWT – the auth middleware reads the cookie and verifies it against the identity provider’s JWKS. That detail makes the honest path cheap: if you can get a real token for a test user, you set it as the cookie and every authenticated route just works, with no special-casing anywhere in the server.
Getting the token means doing what a browser does. The identity provider has no password grant, so a companion command logs a dedicated test user in through the real authorization-code flow – follow the redirect to the login page, replay the sign-in form with credentials from CI secrets, follow the callback back, harvest the session cookie. No bypass, no special code path, nothing in the binary that isn’t there for real users. smolder run takes the harvested cookie with --cookie and sends it on every probe.
One wrinkle surfaced immediately, and it’s a good illustration of why a real round trip earns its keep. The server’s CSRF defense is a same-origin check: a state-changing request must carry an Origin (or Referer) whose host matches the request’s Host. An anonymous prober has neither, so the runner sets an Origin header derived from the base URL it’s hitting. That’s not a workaround – it’s exactly what a same-origin browser request looks like, which is the only kind that should be allowed to write.
Routes that genuinely require a session get marked smoke.AuthRequired(): skipped in the unauthenticated pass, probed – expecting success – only when a credential is supplied. That keeps the anonymous pass from flagging a 401 as a failure while still exercising the route under auth.
POST, and the limits of probing writes
Read probing is the easy 80%. Writes are where a smoke tester has to be honest about what it can and can’t safely do.
Routes carry an effect class. GET, HEAD, and OPTIONS default to ReadOnly; everything else defaults to Mutating. The runner is reads-only by default – a Mutating route never fires unless you pass --include-writes – and --target live runs only ReadOnly routes, so you can point it at production for a sanity sweep with no risk of a write. A write route is probed with a real request body, supplied as smoke.Body(contentType, body) or smoke.Form(values).
The interesting finding came from trying to cover all of them. Most writes should not be black-box probed against a shared, concurrently-hit test fixture, and the reasons are specific. A destructive route – delete, purge, remove – consumes the very fixture that the read probes depend on, and because the runner is concurrent, it races them. A create route hits unique constraints on the second run, or accumulates rows. An upload route wants multipart bodies. An endpoint that calls an LLM is slow, nondeterministic, and costs money per probe.
So the right target was never “probe all writes.” It’s “probe the idempotent ones, skip the rest with a reason.” A handful of authenticated edits – re-saving a profile with its own current values, editing a fixture-owned record – prove the full authenticated write path end to end: cookie, same-origin header, form body, a real database write, a redirect. The destructive and create-shaped and upload-shaped routes are marked skipped, each with a categorized reason that lives in the manifest. That’s not unfinished work; it’s the boundary of what idempotent black-box probing can honestly assert, written down.
This is also where the default expectation got corrected by reality. My first instinct was to pass anything that wasn’t a 5xx, on the theory that a 4xx means the handler ran. That hides too much: a 404 on a route you deliberately probe means either the route is dead or your example is wrong and you’re testing nothing; a 401 on a route you expected to be public is a real regression. The default is 2xx/3xx, and the non-success cases are made explicit instead – Status(403) for an admin route that should reject, AuthRequired for one that needs a session, Skip for one that isn’t a GET target. Only a 5xx is a failure nobody has to think about. Everything else, you say what you mean.
Pure Go, no dependencies
The module’s go.mod has no require block. It’s the standard library and nothing else.
That’s a deliberate constraint, not an accident of a small codebase. A test tool that pulls in a transitive dependency graph is a test tool that can break your build on a go mod tidy, fail govulncheck on someone else’s CVE, or drag a supply-chain incident into the one part of your repo that exists to increase confidence. Zero dependencies means the thing you added to catch regressions can’t itself become one. It also means it vendors cleanly and runs as a Go tool directive
– go tool smolder – so a consuming repo invokes the CLI straight from its vendored copy with no separate install and no network at build time.
A route smoke tester is a small thing. It should weigh nothing.
What it doesn’t do
It is not a contract tester. It does not check response bodies against a schema, only status by default; richer per-route body assertions are a natural extension I haven’t needed yet. It does not test authenticated behavior deeply – it confirms a write succeeds, not that it wrote the right thing. It will not catch a handler that returns 200 with wrong content. And against a shared fixture it deliberately leaves most writes uncovered, by the reasoning above.
What it does is small and specific: it stands between green unit tests and a 502 in production, and it makes the router enforce that the gap stays closed. A handler can be perfect and still be wired wrong. The only thing that catches wired-wrong is hitting the wired thing, and the only thing that keeps you hitting all of it is a gate the act of adding a route can’t slip past.
The code is on GitHub . It’s under a thousand lines of standard-library Go. That’s the right size for a tool that does exactly one thing.

