full-stack-screenshot-testing
Full-Stack Screenshot Testing
Overview
Render frontend pages with a real backend (real DB, real business logic, zero mocks) and capture screenshots from a single Go test. The Go test is the sole orchestrator — it creates data, starts the API, serves the pre-built frontend, and drives a headless browser.
Core insight: A Go httptest.Server can serve both /api/v1/* (real handlers) and /* (pre-built SPA static files) on the same port. No process coordination, no dev server startup, no MSW.
When to Use
- Reviewing UI design with realistic data at every lifecycle state
- Iterating on frontend changes with fast feedback (build → screenshot → review → fix)
- Verifying that admin pages render correctly after backend/model changes
- Catching visual regressions across order states, error conditions, empty states
When NOT to use:
- Unit testing component logic (use Vitest)
- Testing user interactions/flows (use Playwright E2E)
- API contract testing (use Go integration tests)
Architecture
Go Test (single binary, self-contained)
├── Real DB ← testcontainers or local Postgres
├── Real Engine ← business logic, state machines, etc.
├── Real API Server ← httptest.Server with actual handlers
├── SPA File Server ← serves console/dist/ with index.html fallback
└── Rod Browser ← headless Chromium, screenshots to disk
Prerequisites
# One-time: add rod to Go project
go get github.com/go-rod/rod
# Before each test run: build the frontend
cd console && pnpm build
Quick Reference
| Step | What | How |
|---|---|---|
| 1. Isolate | Build tag | //go:build screenshot |
| 2. Data | Create via real service layer | createFixtureOrder(), advanceOrder() |
| 3. Server | Combined API + SPA | httptest.NewServer(mux) |
| 4. SPA fallback | Serve index.html for unknown paths |
spaFileServer("../console/dist") |
| 5. Browser | Rod headless | rod.New().ControlURL(launcher.New().Headless(true).Launch()) |
| 6. Screenshot | Navigate + wait + capture | page.MustNavigate(url).MustWaitStable() |
| 7. Review | Read PNG in Claude | Read tool on screenshots/*.png |
Implementation
SPA File Server (reusable)
The frontend uses client-side routing — /orders/abc must serve index.html, not 404.
func spaFileServer(dir string) http.Handler {
fsys := os.DirFS(dir)
fileServer := http.FileServerFS(fsys)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "."
}
if _, err := fs.Stat(fsys, path); err != nil {
// SPA fallback
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
return
}
fileServer.ServeHTTP(w, r)
})
}
Test Structure
//go:build screenshot
func TestScreenshots(t *testing.T) {
// 1. Real DB + business logic
db := setupTestDB(t)
engine := setupEngine(t, db)
// 2. Create entities at EVERY lifecycle state
happy := createOrder(t, db, engine, ...) // completed flow
mid := createOrder(t, db, engine, ...) // in-progress
empty := createOrder(t, db, engine, ...) // just created
failed := createOrder(t, db, engine, ...) // error state
// Advance each to its target state via real transitions
// 3. Combined server: API + frontend
apiHandler := buildAPIHandler(db, engine)
distDir, _ := filepath.Abs("../console/dist")
mux := http.NewServeMux()
mux.Handle("/api/v1/", apiHandler)
mux.Handle("/", spaFileServer(distDir))
server := httptest.NewServer(mux)
defer server.Close()
// 4. Screenshot with Rod
browser := launchBrowser(t)
defer browser.Close()
for _, s := range []struct{ name, url, wait string }{
{"list", server.URL + "/orders", "table tbody tr"},
{"detail-happy", server.URL + "/orders/" + happy.ID, "h2"},
{"detail-failed", server.URL + "/orders/" + failed.ID, "h2"},
} {
t.Run(s.name, func(t *testing.T) {
page := browser.MustPage("")
defer page.Close()
page.MustSetViewport(1440, 900, 1, false)
page.MustNavigate(s.url)
page.MustWaitStable()
page.MustElement(s.wait)
page.MustWaitStable()
buf, _ := page.Screenshot(true, nil)
os.WriteFile("screenshots/"+s.name+".png", buf, 0o644)
})
}
}
Run Command
# With local Postgres
TEST_DATABASE_URL="postgres://user@localhost:5432/dbname?sslmode=disable" \
go test -tags screenshot -run TestScreenshots ./tests/ -v
# With testcontainers
USE_TESTCONTAINERS=1 \
go test -tags screenshot -run TestScreenshots ./tests/ -v
Iteration Loop
The power of this setup is the fast iteration cycle:
1. Edit frontend code
2. pnpm build (~1.5s)
3. go test -tags screenshot (~27s, includes 6 screenshots)
4. Review screenshots (Read tool on PNGs)
5. Identify issues → goto 1
Steps 2-4 can be run as a single command. The Go test creates fresh data each run — no stale state.
Common Mistakes
| Mistake | Fix |
|---|---|
Forgetting pnpm build after frontend changes |
Screenshots show stale UI. Always rebuild before testing. |
| Missing SPA fallback | Direct navigation to /orders/abc returns 404. Use spaFileServer. |
| Not creating all lifecycle states | Missing edge cases (empty, error, cancelled). Create one entity per state. |
MustWaitStable() too early |
Call AFTER MustElement(selector) to ensure content rendered, not just page loaded. |
| Rod downloads Chromium on first run | First run ~40s (download). Subsequent runs ~27s. Cache at ~/.cache/rod/. |
| Not using build tag | //go:build screenshot prevents slow browser tests from running in normal go test. |
Coverage Strategy
Create one test entity per visual state the UI can show:
Happy path complete → all pipeline stages green
Mid-progress → partial pipeline, action buttons visible
Just created → empty state, minimal data
Error/failed → error banner, retry options
Cancelled → cancellation banner, no pipeline
Multiple items → table with 3+ rows
Single item → table with 1 row
This ensures screenshots cover every visual branch of the UI.