Browser Tests for Go Apps Driven by the chromedp Package
Drive headless Chrome from Go tests with chromedp: start the app on httptest, click and read the DOM, wait for elements without sleeping, and set timeouts.

Most of a Go web application can be tested with httptest and never see a browser. The part that cannot is JavaScript: a button that calls fetch, a field that validates as you type, a page that renders after a timer. For that you need a real browser, and the question is how much of the Node toolchain you are willing to bring along. chromedp drives Chrome over the DevTools protocol from pure Go, so the browser test is another _test.go file and go test runs it.
Tested with Go 1.25.6, chromedp v0.13.6, Google Chrome 156 (headless), macOS on Apple Silicon (arm64)
This article builds a small browser test on Go 1.25.6 against Chrome 156 in headless mode. If you want the broader trade-off between Go driven browsers, see the Playwright article; chromedp is the lighter option and the better default when you only need Chrome.
The page under test
package main
import (
"fmt"
"net/http"
"sync/atomic"
)
const page = `<!doctype html>
<html><body>
<h1 id="title">Counter</h1>
<button id="inc">Add</button>
<span id="count">0</span>
<p id="late" hidden>loaded</p>
<script>
document.getElementById('inc').addEventListener('click', async () => {
const r = await fetch('/inc', {method: 'POST'});
document.getElementById('count').textContent = await r.text();
});
setTimeout(() => { document.getElementById('late').hidden = false; }, 300);
</script>
</body></html>`
// NewHandler serves a page whose button posts to /inc and shows the new count.
func NewHandler() http.Handler {
var n atomic.Int64
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, page)
})
mux.HandleFunc("POST /inc", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, n.Add(1))
})
return mux
}
func main() {}The handler serves one page and one endpoint. The button posts to /inc, the response body becomes the text of #count, and a paragraph that starts hidden is revealed 300 milliseconds after load. Those two behaviours, an asynchronous request and a delayed render, are exactly what a plain HTTP test cannot see. NewHandler returns an http.Handler rather than starting a server, so the test chooses how to host it, and the page is the same one production would serve.
Start the app and the browser
package main
import (
"context"
"net/http/httptest"
"testing"
"time"
"github.com/chromedp/chromedp"
)
func browser(t *testing.T) (context.Context, string) {
t.Helper()
srv := httptest.NewServer(NewHandler())
t.Cleanup(srv.Close)
opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.Flag("headless", true))
actx, cancelAlloc := chromedp.NewExecAllocator(t.Context(), opts...)
t.Cleanup(cancelAlloc)
ctx, cancel := chromedp.NewContext(actx, chromedp.WithErrorf(quiet))
t.Cleanup(cancel)
ctx, cancelTimeout := context.WithTimeout(ctx, 30*time.Second) // a stuck browser fails the test instead of hanging it
t.Cleanup(cancelTimeout)
return ctx, srv.URL
}
func TestClickUpdatesTheCount(t *testing.T) {
ctx, url := browser(t)
var title, count string
err := chromedp.Run(ctx,
chromedp.Navigate(url),
chromedp.Text("#title", &title, chromedp.ByID),
chromedp.Click("#inc", chromedp.ByID),
chromedp.Click("#inc", chromedp.ByID),
chromedp.Poll(`document.getElementById('count').textContent === '2'`, nil),
chromedp.Text("#count", &count, chromedp.ByID),
)
if err != nil {
t.Fatal(err)
}
t.Logf("title=%q count=%q", title, count)
}
func TestWaitVisibleInsteadOfSleep(t *testing.T) {
ctx, url := browser(t)
var text string
start := time.Now()
err := chromedp.Run(ctx,
chromedp.Navigate(url),
chromedp.WaitVisible("#late", chromedp.ByID),
chromedp.Text("#late", &text, chromedp.ByID),
)
if err != nil {
t.Fatal(err)
}
t.Logf("text=%q waited at least 300ms: %v", text, time.Since(start) >= 300*time.Millisecond)
}
func TestMissingElementTimesOut(t *testing.T) {
ctx, url := browser(t)
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
err := chromedp.Run(ctx, chromedp.Navigate(url), chromedp.WaitVisible("#nope", chromedp.ByID))
t.Logf("error: %v", err)
if err == nil {
t.Fatal("expected a timeout")
}
}
// quiet drops the "could not unmarshal event" lines a newer Chrome makes an older cdproto print.
func quiet(string, ...any) {}browser is the setup every test shares, and it does four things.
- Host the app.
httptest.NewServerlistens on a random loopback port, andt.Cleanup(srv.Close)shuts it down. The test navigates tosrv.URL, so nothing is hard coded. - Allocate Chrome.
chromedp.NewExecAllocatorstarts a Chrome process with the default flags plusheadless. chromedp finds the binary on the usual paths for each operating system; setchromedp.ExecPathwhen it is somewhere unusual, as it often is in CI images. - Create a browser context with
chromedp.NewContext. A context is a tab. Cancelling it closes the tab, and cancelling the allocator context closes Chrome, which is why both are registered witht.Cleanup. - Add a deadline. A context derived with
context.WithTimeoutmakes any action that waits forever fail after 30 seconds. Without it, a page that never loads leaves the test hanging until the package timeout of ten minutes.
Because Chrome starts for every test here, each test pays the start-up cost. For a larger suite, create the allocator once in TestMain and a new tab (chromedp.NewContext(parent)) per test; tabs are cheap and isolated enough for most work. See the TestMain article for the pattern.
Actions, in order
chromedp.Run(ctx, actions...) executes its arguments in sequence, and each one is an action such as Click, Text or WaitVisible. They read like a script, and a failure names the step that failed.
go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|=== PAUSE|=== CONT|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: # #' title="Counter" count="2"
--- PASS: TestClickUpdatesTheCount
text="loaded" waited at least 300ms: true
--- PASS: TestWaitVisibleInsteadOfSleep
error: context deadline exceeded
--- PASS: TestMissingElementTimesOutTestClickUpdatesTheCount navigates, reads the heading, clicks #inc twice and reads #count. The result is count="2", which is correct only if both clicks reached the server, the server answered, and the page's JavaScript wrote the answer into the DOM. That is the whole stack in six lines. Notice the Poll between the clicks and the read. A click returns as soon as the event is dispatched, not when the fetch it started finishes, so reading #count straight away would sometimes return 0 or 1. Polling for the condition you expect is the fix, and it is the opposite of time.Sleep.
Wait for state, never for time
The second test is the one to copy. The paragraph #late becomes visible after 300 milliseconds. chromedp.WaitVisible("#late") blocks until the element is visible, then Text reads it: "loaded". The test logs that it waited at least 300 ms, which confirms it really waited and did not just find the element already there.
A time.Sleep(400 * time.Millisecond) would also pass on a fast machine and fail on a loaded CI runner, and it makes every run slower than necessary. A wait on state is as fast as the page allows and as slow as it needs to be. The general rule for flaky tests is in the flaky tests article. Browser tests are where it is broken most often, so make "no sleeps" a review rule.
chromedp offers several wait actions: WaitReady for a loaded element, WaitVisible, WaitNotVisible, WaitEnabled, WaitSelected, and Poll for an arbitrary JavaScript expression. Prefer the narrowest one that expresses the condition you mean.
Failing quickly
TestMissingElementTimesOut waits for an element that does not exist, under a 500 ms context. The error is context deadline exceeded. A passing suite never shows you that line, but a failing one will, and what matters is that it appears after half a second and not after a minute. Keep the outer timeout generous for the whole test and shorten it with a child context for the waits that you expect to be short.
That error says nothing about which selector was missing. Wrap the call with context about the step, fmt.Errorf("waiting for the confirmation banner: %w", err), or run the steps in separate Run calls so the line number identifies the one that failed. Taking a screenshot on failure is also worth the five lines: chromedp.FullScreenshot into a buffer, written to t.TempDir() or the CI artefacts directory.
Pinning, versions and the noisy log
The recorded lab ran without log output, and that took a line of code. The DevTools protocol evolves with Chrome, and chromedp generates its types from a snapshot of the protocol in the cdproto module. A Chrome newer than that snapshot sends values the types do not know, and chromedp logs could not unmarshal event: ... unknown IPAddressSpace value: Loopback for each one. The tests still pass, since the failing events are ones this test does not use, but a log full of ERROR lines hides real errors.
Two fixes. Upgrade chromedp to a release whose cdproto matches your Chrome, or silence the logger with chromedp.WithErrorf as the lab does. The first is better when you control the Chrome version, and the second when CI images float. Pin the Chrome version in CI, and pin chromedp in go.mod. The lab uses v0.13.6 because the newest releases changed the action types and need a newer Go.
Keeping browser tests few
A browser test costs one to two seconds against milliseconds for a handler test, it needs Chrome installed, and it fails for reasons that have nothing to do with your code: animations, fonts, a slow runner. Test the behaviour that needs a browser, which is client side logic and the integration of page and API, and test everything else lower down. A practical limit is a handful of journeys through the critical paths, each run once, on top of the test pyramid rather than instead of it.
- Do not assert on pixel layout or exact CSS unless that is the feature.
- Select by stable attributes you control, such as
data-testid, not by text or by position. - Reset server state per test by starting a new
httptestserver, as the helper does, instead of cleaning a shared one. - Run them in a separate package or behind a build tag so
go test ./...stays quick, as the integration tag article shows.
The chromedp project documents the full action list in its package reference, and the Chrome team documents the DevTools protocol it speaks.


