E2E Tests — Find the Bug¶
Each snippet below is a Go E2E test (or supporting code) with a real defect. Find it, name it, and propose the fix.
Bug 1 — The friendly sleep¶
func TestE2E_OrderShipped(t *testing.T) {
id := createOrder(t)
time.Sleep(2 * time.Second) // wait for worker
require.Equal(t, "shipped", statusOf(t, id))
}
Bug. Fixed sleep. On a slow CI runner the worker may take 3 seconds; on a fast laptop it takes 200 ms. The test is either flaky or slow.
Fix. Poll until the expected state is observed, with an explicit deadline.
require.Eventually(t, func() bool {
return statusOf(t, id) == "shipped"
}, 30*time.Second, 100*time.Millisecond)
Bug 2 — Shared tenant¶
const tenantID = "e2e-tenant"
func TestE2E_CreateUser(t *testing.T) {
t.Parallel()
createUser(t, tenantID, "alice")
require.Equal(t, 1, countUsers(t, tenantID))
}
func TestE2E_DeleteUser(t *testing.T) {
t.Parallel()
createUser(t, tenantID, "bob")
deleteUser(t, tenantID, "bob")
require.Equal(t, 0, countUsers(t, tenantID))
}
Bug. Both tests run in parallel against the same tenant. One test's count sees the other test's data.
Fix. Allocate a fresh tenant per test:
Bug 3 — Browser without wait¶
chromedp.Run(ctx,
chromedp.Navigate(baseURL+"/login"),
chromedp.SendKeys(`#email`, "alice@example.com"),
chromedp.SendKeys(`#password`, "secret"),
chromedp.Click(`button[type=submit]`),
chromedp.Text(`#greeting`, &greeting),
)
Bug. After Click, the navigation to /dashboard is asynchronous. chromedp.Text reads from the login page where #greeting does not exist.
Fix. Insert chromedp.WaitVisible('#greeting') before reading.
Bug 4 — Token in URL¶
req, _ := http.NewRequest("GET",
fmt.Sprintf("%s/orders/%s?token=%s", baseURL, id, token), nil)
resp, _ := client.Do(req)
Bug. Auth token in the URL. It will appear in server access logs, in proxy logs, in t.Logf output, and in any HTTP recording the suite saves on failure. This is a credential leak waiting to happen.
Fix. Use the Authorization header:
Bug 5 — Errors silently dropped¶
Bug. The err from http.Get is ignored. If the network fails or the server is down, resp is nil and the next line panics with a nil pointer dereference — masking the real failure.
Fix.
resp, err := http.Get(baseURL + "/health")
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
Bug 6 — Cleanup that races¶
func TestE2E_OrderRoundTrip(t *testing.T) {
id := createOrder(t)
go deleteOrder(t, id) // cleanup in background
require.Equal(t, "confirmed", statusOf(t, id))
}
Bug. The cleanup goroutine may delete the order before the assertion runs. Even if it doesn't, calling t.Fatal from another goroutine is undefined when the test has already returned.
Fix. Use t.Cleanup:
Bug 7 — No deadline on the test context¶
Bug. No deadline. If #x never appears, the test hangs until the CI runner's wall-clock kill (often 30+ minutes), producing zero diagnostics.
Fix.
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
chromedp.Run(ctx, /* ... */)
Bug 8 — Reused port¶
func TestMain(m *testing.M) {
cmd := exec.Command("go", "run", "./cmd/svc", "-addr=:8080")
_ = cmd.Start()
time.Sleep(2 * time.Second)
os.Exit(m.Run())
}
Bug. Hard-coded :8080. A second test binary on the same host (parallel CI, developer running the suite twice) collides with EADDRINUSE.
Fix. Bind to :0 (kernel assigns a free port), discover the port via the service's startup log line or a /health endpoint that echoes the listen address, and pass it to tests via env.
Bug 9 — Retry on bad request¶
func doRetry(req *http.Request) (*http.Response, error) {
var lastErr error
for i := 0; i < 5; i++ {
resp, err := http.DefaultClient.Do(req)
if err == nil && resp.StatusCode < 500 {
return resp, nil
}
lastErr = err
time.Sleep(time.Second)
}
return nil, lastErr
}
Bug. The condition resp.StatusCode < 500 accepts 4xx responses without retrying — which is correct. But the loop also does not retry on 502 if err == nil, because the early return path is wrong: err == nil && resp.StatusCode < 500 returns success even for 4xx like 404 (fine) and returns success for 500+ only when err != nil (which never happens for an HTTP response with a body). Trace it: a 502 returns (resp, nil) with StatusCode 502 → condition nil && 502 < 500 is false → fall through → sleep → retry. That part is fine. The bug is different: the request body (req.Body) is consumed on the first attempt and is nil on the second. The retry sends an empty body.
Fix. Capture the body bytes once, rebuild a fresh io.Reader per attempt, and use req.Clone(ctx) with a new body:
body, _ := io.ReadAll(req.Body)
for i := 0; i < 5; i++ {
cloned := req.Clone(req.Context())
cloned.Body = io.NopCloser(bytes.NewReader(body))
// ...
}
Bug 10 — Compose left running on panic¶
func TestMain(m *testing.M) {
exec.Command("docker", "compose", "up", "-d").Run()
code := m.Run()
exec.Command("docker", "compose", "down", "-v").Run()
os.Exit(code)
}
Bug. If m.Run() panics or the process is killed, down never runs and the compose stack leaks. Successive runs start failing because the ports are held.
Fix. Register a signal handler and use defer-style teardown via a wrapper function:
func TestMain(m *testing.M) {
up()
defer down()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
go func() { <-sig; down(); os.Exit(2) }()
os.Exit(m.Run())
}
defer in TestMain does not run if os.Exit is called, so the wrapper pattern wraps the call inside a function and uses defer correctly inside it — or registers down as atexit-style via signal traps.
Bug 11 — Comparing maps with a typo¶
Bug. JSON numbers decoded into any arrive as float64, not int. The assertion fails with 30 != 30.0 even though the server returned the correct value.
Fix. Decode into a typed struct, or use a JSON-aware matcher:
var got struct{ Name string; Age int }
json.NewDecoder(resp.Body).Decode(&got)
require.Equal(t, "Alice", got.Name)
require.Equal(t, 30, got.Age)
Bug 12 — Logging the token¶
Bug. resp.Header may contain a Set-Cookie with a session token. On a failing test, CI logs the header and the token leaks into CI's permanent archive.
Fix. Redact sensitive headers before logging:
Or, better, only log on failure with a helper that scrubs known sensitive fields.
Bug 13 — t.Parallel after shared setup¶
Bug. seedCatalog mutates a global. Running multiple tests in parallel each calls seedCatalog racing on the same global state.
Fix. Do shared setup in TestMain, or guard with sync.Once if it must run lazily. The t.Parallel call should follow only steps that are safe to run concurrently with other tests.
Bug 14 — Decoded type drift¶
var out struct {
Order map[string]any
}
json.NewDecoder(resp.Body).Decode(&out)
qty := out.Order["qty"].(int)
Bug. JSON numbers in map[string]any are float64. The type assertion .(int) panics. The test fails not with "expected 3 got 4" but with "interface conversion: float64 is not int" — useless.
Fix. Use a typed struct:
Or decode with UseNumber() and call .Int64() explicitly.
Bug 15 — Test runs the wrong service¶
Bug. Hard-coded URL. The test passes on the laptop where the developer has their service on 8080 and fails everywhere else.
Fix. Read from E2E_BASE_URL. Skip the test if unset. No hard-coded URLs anywhere in the suite.
Bug 16 — Cleanup forgets the parent¶
func TestE2E_OrderInTenant(t *testing.T) {
tenant := createTenant(t)
order := createOrder(t, tenant.ID, "WIDGET", 1)
t.Cleanup(func() { deleteOrder(t, order.ID) })
// tenant cleanup missing
}
Bug. Order cleanup runs, tenant cleanup does not. The tenant accumulates in the staging database, eventually slowing it down.
Fix. The createTenant helper should register its own cleanup:
func createTenant(t *testing.T) Tenant {
// ... create ...
t.Cleanup(func() { deleteTenant(t, tenant.ID) })
return tenant
}
Cleanup co-located with creation. The test does not need to remember.
Bug 17 — Test order dependency¶
var sharedTenantID string
func TestE2E_CreateTenant(t *testing.T) {
sharedTenantID = createTenant(t)
}
func TestE2E_UseTenant(t *testing.T) {
require.NotEmpty(t, sharedTenantID)
// use it
}
Bug. TestE2E_UseTenant depends on TestE2E_CreateTenant running first. go test -run TestE2E_UseTenant fails. go test -shuffle on fails randomly.
Fix. Each test creates its own tenant. No order dependency.