Skip to content
gohacks.dev
Testing

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.

8 min read
Summarize in AI
Flat illustration of a test report with one failing test.

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

app.goGo
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

app_test.goGo
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.

  1. Host the app. httptest.NewServer listens on a random loopback port, and t.Cleanup(srv.Close) shuts it down. The test navigates to srv.URL, so nothing is hard coded.
  2. Allocate Chrome. chromedp.NewExecAllocator starts a Chrome process with the default flags plus headless. chromedp finds the binary on the usual paths for each operating system; set chromedp.ExecPath when it is somewhere unusual, as it often is in CI images.
  3. 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 with t.Cleanup.
  4. Add a deadline. A context derived with context.WithTimeout makes 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.

Shell
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]+: #    #'
Text
    title="Counter" count="2"
--- PASS: TestClickUpdatesTheCount
    text="loaded" waited at least 300ms: true
--- PASS: TestWaitVisibleInsteadOfSleep
    error: context deadline exceeded
--- PASS: TestMissingElementTimesOut

TestClickUpdatesTheCount 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 httptest server, 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.

Also inWeb
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.