End to End Tests for Go Web Apps With Playwright Driver
End to end tests for Go web apps with playwright-go: start the app on a real port, use locators and auto-waiting, save traces and keep the suite small.

Some bugs live between the layers: a form that posts to a route that was renamed, a button wired to a function that no longer exists, a page that works until JavaScript loads out of order. Unit tests and handler tests will not find them, because no single layer is wrong. A browser test will, because it drives the whole stack the way a user does. This article covers Playwright for Go on Go 1.25.6: what the tool is, how a test is shaped, and how to keep the suite useful.
Tested with Go 1.25.6; the application and its server-side checks were run, the playwright-go code is described from its documentation and was not run
A note on what was run. The application and its server-side checks ran for this article. The Playwright calls did not, because the library needs a driver and browser binaries downloaded, which I did not do here. Where code touches Playwright it is described from the project's documentation and marked, and you should confirm the API against the version you install. For a Go-only alternative that did run, see chromedp.
What playwright-go is
Playwright is a browser automation library from Microsoft. It controls Chromium, Firefox and WebKit through one API. It is written for Node.js, and the Go package github.com/playwright-community/playwright-go is a community port that starts the Playwright driver, a Node-based process, and talks to it. That has consequences: the first run downloads the driver and the browsers, and your test environment needs to be able to run them.
Compared with chromedp, which speaks to Chrome directly and needs only a Chrome binary:
| playwright-go | chromedp | |
|---|---|---|
| Browsers | Chromium, Firefox, WebKit | Chrome and Chromium |
| Install | Driver and browsers downloaded by the library | A Chrome binary |
| Auto-waiting actions | Built in, on every action | You wait explicitly |
| Assertions | expect helpers that retry | Write your own polling |
| Debugging | Trace viewer, video, screenshots | Screenshots |
| Weight | Heavier: Node driver | Lighter: pure Go |
Choose Playwright when you need more than one browser engine, want the trace viewer, or your team already knows Playwright from JavaScript. Choose chromedp when Chrome is enough and you prefer fewer moving parts.
The application
The app is the same small counter used elsewhere in this series: a page with a button and a number, and an endpoint that increments it.
package main
import (
"fmt"
"net/http"
"sync/atomic"
)
const page = `<!doctype html>
<html lang="en"><body>
<h1>Counter</h1>
<button id="inc" data-testid="inc">Add</button>
<span data-testid="count">0</span>
<script>
document.querySelector('[data-testid=inc]').addEventListener('click', async () => {
const r = await fetch('/inc', {method: 'POST'});
document.querySelector('[data-testid=count]').textContent = await r.text();
});
</script>
</body></html>`
// NewHandler is the application under test. An end to end test starts it on a real port.
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 only change from a plain page is the data-testid attributes on the button and the counter. They are the hooks the tests use, and they exist for tests, so they do not change when a designer renames a class or restructures the layout.
Test the server half first
A scenario has a server half and a browser half. The server half runs without a browser, in milliseconds, and tells you whether the browser has anything to talk to.
package main
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Before any browser runs, check the server half and the hooks a browser test will use.
func TestServerSideOfTheJourney(t *testing.T) {
srv := httptest.NewServer(NewHandler())
t.Cleanup(srv.Close)
resp, err := http.Get(srv.URL)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Logf("GET /: status=%d type=%s", resp.StatusCode, resp.Header.Get("Content-Type"))
t.Logf("page has data-testid hooks: inc=%v count=%v", strings.Contains(string(body), `data-testid="inc"`), strings.Contains(string(body), `data-testid="count"`))
for i := 1; i <= 2; i++ {
resp, err := http.Post(srv.URL+"/inc", "text/plain", nil)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Logf("POST /inc #%d -> %s", i, b)
}
}go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: # #' GET /: status=200 type=text/html; charset=utf-8
page has data-testid hooks: inc=true count=true
POST /inc #1 -> 1
POST /inc #2 -> 2
--- PASS: TestServerSideOfTheJourneyThe test starts the handler on httptest.NewServer, fetches the page and posts twice. The log shows status=200 type=text/html; charset=utf-8, confirms both test ids are in the markup, and shows the counter endpoint returning 1 and then 2. If this fails, there is no point launching a browser. If it passes, a browser test is checking the integration: that the page's JavaScript wires the button to the endpoint and puts the answer in the DOM.
Installing the driver
The library downloads what it needs with an explicit call. In a test setup, or a one-time step:
// described from the playwright-go documentation; not run here
if err := playwright.Install(&playwright.RunOptions{Browsers: []string{"chromium"}}); err != nil {
log.Fatalf("install playwright: %v", err)
}Run it once per machine or build image, not in every test. In CI, cache the download directory, and in Docker bake it into the image. The --with-deps option of the CLI also installs the system libraries a browser needs on Linux. Pin the playwright-go version in go.mod: the driver version is tied to it, and a mismatch is a source of confusing failures.
A test, described
The shape of a test, from the documentation, is below. It was not run for this article.
// described from the playwright-go documentation; not run here
func TestCounterScenario(t *testing.T) {
srv := httptest.NewServer(NewHandler())
t.Cleanup(srv.Close)
pw, err := playwright.Run()
if err != nil {
t.Fatalf("start playwright: %v", err)
}
t.Cleanup(func() { _ = pw.Stop() })
browser, err := pw.Chromium.Launch()
if err != nil {
t.Fatalf("launch: %v", err)
}
t.Cleanup(func() { _ = browser.Close() })
page, err := browser.NewPage()
if err != nil {
t.Fatalf("new page: %v", err)
}
if _, err := page.Goto(srv.URL); err != nil {
t.Fatalf("goto: %v", err)
}
inc := page.GetByTestId("inc")
if err := inc.Click(); err != nil {
t.Fatalf("click: %v", err)
}
if err := inc.Click(); err != nil {
t.Fatalf("click: %v", err)
}
expect := playwright.NewPlaywrightAssertions()
if err := expect.Locator(page.GetByTestId("count")).ToHaveText("2"); err != nil {
t.Fatalf("count: %v", err)
}
}Read it in the order a reader would. The server starts first, on a free port. Playwright starts as a separate process and is stopped in t.Cleanup. The browser launches headless by default. A Page is a tab. page.Goto(srv.URL) loads the page and waits for the load event.
Locators
page.GetByTestId("inc") returns a Locator: a description of how to find an element, evaluated lazily each time you use it. That laziness is the point. The page may re-render between two uses and the locator still finds the current element. Prefer, in order:
- By role and name,
page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "Add"}), which matches how users and assistive technology find things and doubles as an accessibility check. - By label or visible text for form fields and links.
- By test id, as here, when nothing user-facing is stable.
- CSS or XPath last, since they encode layout and break when the layout changes.
Auto-waiting and assertions
Playwright actions wait for the element to be attached, visible, stable and enabled before acting, so Click on a button that appears after a fetch works. The expect assertions retry until the condition holds or a timeout passes, which is why ToHaveText("2") is correct even though the counter updates after an asynchronous request. This replaces the time.Sleep that makes browser tests flaky: there is none in the test above, and there should never be one in yours. The general method for diagnosing a flaky test is in the flaky tests guide.
Structuring a suite
Share the browser, not the page. Launching a browser takes a second or more. Launch one in TestMain or a sync.Once, and give each test a fresh BrowserContext (an isolated profile with its own cookies and storage), which is cheap: browser.NewContext(). Tests then cannot see each other's state.
Seed state through the API, not the UI. A test that needs a logged-in user should create the user with a direct request or a database call and set the session cookie on the context, rather than filling in the sign-in form in every test. Test the sign-in form once.
Keep each test a scenario. A browser test should cover something only a browser can: a multi-step flow, client-side logic, an integration between page and API. A test that asserts a validation message for each field is a unit test wearing a costume.
Isolate the server's state. Start a fresh httptest server and a fresh database or schema per test or per package, as in the isolation approaches for databases. A shared server with state carried between tests produces order dependent failures that are very expensive to debug in a browser.
Collect evidence on failure. Playwright can record a trace (context.Tracing().Start(...), stopped to a zip file) that the trace viewer replays with DOM snapshots, network calls and a timeline, and take screenshots with page.Screenshot. Save them to t.TempDir() or the CI artefacts folder in a t.Cleanup that runs when t.Failed(). A failing browser test without a trace is hard to act on.
Run them in their own tier. Put the files behind //go:build e2e, as in the integration tag article, run them after the unit and integration tests, and give them their own CI job with the driver and browsers cached.
Running in CI and in Docker
A browser needs system libraries that minimal images lack. Two routes work. Use an image that already has them, such as the Playwright project's own images (which carry browsers and dependencies for a given version), and run your Go tests inside it, or install the dependencies in your own image with the library's install command and its --with-deps option. Either way, pin the Playwright version and the image tag together, since a driver and a browser build are released as a pair.
Cache what is expensive: the Go build cache, the module cache and the directory where Playwright stores browsers. A cold job spends a minute downloading Chromium; a warm one starts immediately. Run the browser tier as its own job after the faster ones, so a failure in a unit test stops the pipeline before the slow job spends minutes.
On shared runners, expect timing to be worse than on a laptop. Raise Playwright's default timeouts for the CI environment, and not by sprinkling sleeps: the setting is global, and assertions still return the moment the condition holds. Run with a small number of workers, because several browsers on a two-core runner starve each other and produce timeouts that look like bugs.
Debugging a failing test
Start from the evidence the test saved. Open the trace in the trace viewer to see the sequence of actions, the page as it looked before and after each, the console messages and the network requests. In nine failures out of ten the cause is visible there: an element that never appeared, a request that returned an error, a click on something covered by an overlay. Running with a visible browser (Headless: false) and slowing actions down helps locally. The Playwright inspector can pause a test and let you try locators interactively.
Common causes, in rough order of how often they occur: the locator matched more than one element (Playwright reports a strict mode violation and tells you how many matched); the page is still loading data and the assertion needs a longer timeout; the state left by an earlier test; a request to a third party that fails in CI; and a real bug. Treat the real-bug case as the interesting one and make sure the first four are ruled out by the design of the suite.
Controlling the network and the clock
Browser tests become far more reliable when they control what the page cannot. Playwright can intercept requests with page.Route, so a call to a payment provider or an analytics endpoint is answered by a stub and never leaves the machine. It can also set the browser's time with its clock API, in recent versions, so a test of a countdown or a session expiry does not wait. Combine that with a deterministic server (an injected clock and fixed IDs, as in determinism) and the scenario produces the same page every run. Without it, a test that asserts on "created 3 minutes ago" is a flake waiting for a slow machine.
Costs and limits
Browser tests are slow, one to ten seconds each, need a heavy toolchain and fail for reasons unrelated to your change: a font that did not load, a CI machine under load, an animation that was still moving. The model in the test pyramid article shows what a suite of a hundred of them does to a pipeline's reliability. Keep the count to the scenarios that earn money or trust, and test everything else lower.
Further limits worth knowing:
- Visual correctness is not checked by DOM assertions. Screenshot comparison is available and tends to be noisy across machines.
- Real browsers differ from your users' browsers in version, extensions and network. Cross-browser runs catch some of this and multiply the cost.
- The Go port trails the Node original in features and release speed, and its API names follow the Go port, not the JavaScript docs, so translate examples with care.
- Cookie banners, captchas and third-party scripts in staging environments cause failures. Stub or block them in the test context.
- Authentication through an identity provider is better replaced by a test-only session endpoint, and the provider's flow tested manually.
A checklist before adding one
- Can a handler or router test check this? Write that instead.
- Is the scenario one a user would pay for or leave over? If not, skip it.
- Do the elements have stable roles, labels or test ids?
- Does the test set up its own data through the API?
- Does it save a trace on failure?
- Does it run in under ten seconds, and fail for one reason?
The official documentation is the reference: the Playwright documentation for concepts and the playwright-go package for the Go API. For the Go code that serves the pages, the recorder article and the testing guide are the places to start.


