End-to-end tests for Go (net/http).
httptest.NewRecorder tells you the handler wrote a 200 and some bytes. It cannot tell you those bytes are a page with a missing field on it, because html/template renders a nil into nothing and says nothing about it.
$ npx smolanalytics test \
--url https://staging.yourapp.com \
--test "the invoice page shows the customer name, the total and a download link"Three specific things named in one sentence. A missing field renders as empty in html/template, and empty is what this catches.
how do I write end-to-end tests for a Go (net/http) app?
For a Go web app built on net/http, end-to-end testing without test code means one sentence per page or flow and npx smolanalytics test --url against a running URL. An agent drives a real browser, reads the page through its accessibility tree, and returns passed, failed, stale or errored. It is worth adding on top of httptest because of one specific blind spot: html/template renders a missing or nil field as an empty string with no error, so a renamed struct field gives you a 200, a well-formed page, and no customer name on the invoice. A sentence that names the three things which must be on the page catches that; a status-code assertion cannot. The same is true of a handler that writes a partial response before erroring, where the header is already sent and the metric records a success. On a pull request one comment says what broke, edited in place, from your own CI runner using the GITHUB_TOKEN GitHub Actions provides free, with nothing written to your repo. Passing runs are recorded and replay with no model call at all. If your service is a JSON API with no interface, the agent drives browsers only and this is the wrong tool. The same walk also maintains your tracking calls in the analytics you already use. 14-day trial at Pro limits, no card, then $19/month.
Go's standard library gives you an unusually good in-process testing story and one specific blind spot: templates. html/template renders a missing or nil field as an empty string and moves on, so a struct field renamed in one place and not the other produces a page that is structurally perfect and missing the customer's name. The handler returned 200. The bytes were written. Your httptest assertion on the status code passes, and so does the one on the substring you thought to check. Naming the three things that must be on the page in one English sentence is a stronger check than the assertions most people write, and it costs one line.
The second one is the handler that writes a partial response before it hits an error: the header is already sent, so the error handler cannot change the status, and the user gets a 200 with half a page. From the server's side the log shows an error and the metric shows a success.
A boundary, plainly: the agent drives web browsers. If your Go service is a JSON API with no interface, this is not the tool — the standard library's own test support already covers you well. It is for the server-rendered pages and for the frontend that sits in front of the API.
The second half is instrumentation. In a Go service the moment worth measuring is inside a handler, and nobody puts analytics there, which is why server-side events are the ones most often missing entirely. The same walk that just used the page knows which actions exist, so it writes and maintains the tracking calls in whichever SDK you already run — PostHog, Mixpanel, Amplitude, Google Analytics, Plausible or Segment.
How it works walks the whole loop from the sentence to the comment on the pull request, instrumentation covers the second half — the tracking calls the same walk writes and maintains — and the free tools run without an account. The docs have the flags, the suite format and the CI step.
Pro $19/mo, 100 tested pull requests included, unlimited projects, after a 14-day trial at Pro limits with no card. full pricing, including overage →
One sentence, on your Go (net/http) app, tonight.
Point it at a URL that is already running and describe what should work. The first run uses the agent; once it passes it is recorded, so every run after that replays with no model at all — measured on our own site, 8.0s the first time and 1.4s the second.