rgoussu@goussu: ~/library/go/testing
~/library/go/testing cat end-to-end-testing.md

End-to-end testing — API clients, browsers, contracts

# Go end-to-end testing — API-level E2E with plain net/http, browser automation via playwright-go/rod/chromedp, pact-go contracts, and flakiness discipline.

Conceptsaved 2026-08-09 #go#testing#e2e#contracts#automation

Overview

End-to-end tests exercise the deployed system through its public surface — HTTP API, browser UI, or both — with real infrastructure underneath. Go needs less specialist tooling here than the JVM: a plain net/http client plus encoding/json is a perfectly good API-testing DSL, so there is no REST-Assured to learn. The genuinely third-party parts are browser automation (playwright-go, rod, chromedp) and consumer-driven contracts (pact-go), which trade breadth of E2E for precision and speed.

Key points

  • API E2E is plain net/http: build a tiny typed client helper per suite (do-request, decode, assert status) and the tests read as well as any DSL — see the example.
  • E2E tests are still go test: same runner, same -run filtering, same CI wiring; point the suite at a base URL from the environment.
  • Browser automation options: playwright-go (bindings to the Playwright driver — auto-waiting, tracing, multi-browser), go-rod/rod (pure-Go DevTools protocol, no Node dependency), chromedp (the veteran DevTools library, Chrome-only). Playwright for feature breadth, rod for a Go-native dependency graph.
  • Contract testing with pact-go: consumer tests generate a pact file; the provider verifies it in its own CI. Catches integration breakage without a shared environment — prefer it to broad cross-service E2E.
  • Environment management: docker compose up for service+dependencies suites (testcontainers-go can drive compose files too); kind for applications whose contract includes Kubernetes manifests — spin a real cluster in CI, kubectl apply, test.
  • Keep the true-E2E layer thin: a smoke suite over deployed environments; everything that can move down to integration or contract level should.
  • Flakiness is the tax: E2E suites die by nondeterminism, not by missing coverage — budget discipline for it from day one.

Details

Flakiness discipline

  • Poll, never sleep: replace time.Sleep with eventually-style polling — require.Eventually(t, cond, 10*time.Second, 100*time.Millisecond) from testify, or a hand-rolled ticker loop. Asynchronous effects (queue consumption, cache warmup) become bounded waits instead of races.
  • t.Parallel hazards: parallel E2E tests sharing one deployed environment collide on data (same user, same order IDs) and on rate limits. Either namespace all data per test (unique IDs, per-test tenants) or keep the E2E suite serial — a slow green suite beats a fast flaky one.
  • Own your test data: create what you assert on inside the test; never depend on seeded state that another test (or run) can mutate.
  • Deadlines everywhere: context.WithTimeout on every request so a wedged environment fails the test in seconds, not after the CI job timeout.
  • Quarantine, don't retry blindly: automatic retries hide real races; tag known-flaky tests, track them, fix or delete.

Choosing the E2E surface

Surface Tool Use when
HTTP API net/http + JSON helpers Always the first choice — fastest, most debuggable
Browser UI playwright-go / rod / chromedp The UI logic itself is the deliverable
Cross-service contract pact-go Two teams, two deploy cadences, one interface
Full stack in CI docker compose / kind Deployment topology is part of what you verify

Examples

// A ten-line client helper replaces an API-testing DSL.
func postJSON[T any](t *testing.T, url string, body any) (int, T) {
    t.Helper()
    var out T
    buf, _ := json.Marshal(body)
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf))
    req.Header.Set("Content-Type", "application/json")
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        t.Fatalf("POST %s: %v", url, err)
    }
    defer resp.Body.Close()
    _ = json.NewDecoder(resp.Body).Decode(&out)
    return resp.StatusCode, out
}

func TestOrderLifecycle(t *testing.T) {
    base := os.Getenv("E2E_BASE_URL")
    status, order := postJSON[Order](t, base+"/orders", map[string]any{"sku": "A-1"})
    if status != http.StatusCreated {
        t.Fatalf("create: status %d", status)
    }
    require.Eventually(t, func() bool {
        st, o := getJSON[Order](t, base+"/orders/"+order.ID)
        return st == 200 && o.State == "confirmed"
    }, 15*time.Second, 200*time.Millisecond, "order never confirmed")
}

Related