Testing
Unit tests, handler tests, and Playwright E2E testing.
The GOaT stack tests at three levels: unit tests for services, handler tests with httptest, and E2E tests with Playwright.
Unit Tests (Services)
Test business logic with table-driven tests:
func TestGameService_RecordThrow(t *testing.T) {
tests := []struct {
name string
teamID int32
inHole bool
wantScore int32
wantBags int32
}{
{"board hit", 1, false, 1, 1},
{"in the hole", 1, true, 3, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := newTestGameService(t)
game := createTestGame(t, svc)
err := svc.RecordThrow(context.Background(), game.ID, tt.teamID, tt.inHole)
if err != nil {
t.Fatal(err)
}
state, _ := svc.GetGameState(context.Background(), game.ID)
if state.Team1Score != tt.wantScore {
t.Errorf("score = %d, want %d", state.Team1Score, tt.wantScore)
}
})
}
}
Handler Tests
Use httptest.NewRecorder() to test HTTP handlers without a running server:
func TestCreateGame(t *testing.T) {
handler := NewGameHandler(mockGameSvc, mockSSEHub)
req := httptest.NewRequest("POST", "/game/new", nil)
w := httptest.NewRecorder()
handler.CreateGame(w, req)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want %d", w.Code, http.StatusSeeOther)
}
if !strings.HasPrefix(w.Header().Get("Location"), "/game/") {
t.Error("expected redirect to /game/")
}
}
API Handler Tests
Test JSON API endpoints:
func TestAPIGetGameState(t *testing.T) {
handler := NewGameHandler(mockGameSvc, mockSSEHub)
req := httptest.NewRequest("GET", "/api/game/1/state", nil)
req.SetPathValue("id", "1")
w := httptest.NewRecorder()
handler.APIGetGameState(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var state GameStateJSON
json.NewDecoder(w.Body).Decode(&state)
if state.GameID != 1 {
t.Errorf("game_id = %d, want 1", state.GameID)
}
}
Playwright E2E Tests
For full browser testing, use Playwright with a custom test server:
e2e/playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'http://localhost:8080',
},
webServer: {
command: 'go run ../cmd/server/main.go',
port: 8080,
reuseExistingServer: !process.env.CI,
},
});
e2e/tests/game.spec.ts:
import { test, expect } from '@playwright/test';
test('create and join a game', async ({ page }) => {
await page.goto('/');
await page.click('text=New Game');
// Should redirect to game lobby
await expect(page).toHaveURL(/\/game\/\d+/);
// Game code should be visible
const code = page.locator('.font-mono.text-3xl');
await expect(code).toBeVisible();
await expect(code).toHaveText(/^[A-Z0-9]{6}$/);
});
test('real-time scoring updates', async ({ page, context }) => {
await page.goto('/game/1');
// Open second browser for opponent
const page2 = await context.newPage();
await page2.goto('/game/1');
// Score on page 1
await page.click('text=IN +3');
// Verify update appears on page 2 via SSE
await expect(page2.locator('.score')).toContainText('3');
});
Run tests:
cd e2e && npx playwright test
Test Database
Use a separate test database with migrations applied:
func newTestGameService(t *testing.T) *GameService {
t.Helper()
pool, err := pgxpool.New(context.Background(), os.Getenv("TEST_DATABASE_URL"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pool.Close() })
return &GameService{db: pool}
}
Rod E2E Tests (Go-native)
For Go-native E2E testing without a Node.js toolchain, use Rod:
import "github.com/go-rod/rod"
import "github.com/go-rod/rod/lib/launcher"
func TestE2E_FullFlow(t *testing.T) {
// NoSandbox for containers/CI
u := launcher.New().NoSandbox(true).MustLaunch()
browser := rod.New().ControlURL(u).MustConnect()
defer browser.MustClose()
page := browser.MustPage("http://localhost:8080")
// Navigate and interact
page.MustElement("a[href='/new']").MustClick()
page.MustWaitStable()
// Fill form
page.MustElement("input[name='name']").MustInput("test")
page.MustElement("form").MustElement("button[type='submit']").MustClick()
// Assert result
page.MustWaitStable()
el := page.MustElement(".alert-success")
if !strings.Contains(el.MustText(), "Created") {
t.Error("expected success message")
}
}
Why Rod over Playwright?
- Same language as your app (Go) — one
go testcommand - No Node.js dependency
- Faster CI (no
npx playwright install) - Use
NoSandbox(true)for container environments
Integration Tests with Real Databases
Use testing.Short() to skip integration tests in fast runs:
func TestIntegration_CreateTournament(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
pool, cleanup := setupTestDB(t)
defer cleanup()
svc := NewTournamentService(pool)
ctx := context.Background()
tournament, err := svc.CreateTournament(ctx, "Test", "round_robin", nil, 21, nil)
if err != nil {
t.Fatalf("create: %v", err)
}
if tournament.Slug == "" {
t.Error("expected non-empty slug")
}
}
Run unit tests only: go test -short ./...
Run all tests: go test ./...
Pure Logic Tests
For algorithmic code (bracket generation, scoring), test pure functions without any DB:
func TestGenerateRoundRobinSchedule(t *testing.T) {
tests := []struct {
numTeams int
wantCount int
}{
{4, 6}, // 4*3/2
{8, 28}, // 8*7/2
{3, 3},
}
for _, tt := range tests {
matches := GenerateRoundRobinSchedule(tt.numTeams)
if len(matches) != tt.wantCount {
t.Errorf("got %d, want %d", len(matches), tt.wantCount)
}
}
}
Gotchas
req.SetPathValue()is Go 1.22+. Use it in tests to set path parameters.- Table-driven tests scale. Add cases, not test functions.
testing.Short()skip guard — lets you run fast unit tests in dev, full integration in CI.- Rod
NoSandbox(true)— required in Docker/CI containers without a display server. - Playwright still works if your team prefers TypeScript. Set
reuseExistingServer: truefor local dev. - Clean up test data between tests. Use transactions that rollback, or truncate tables in
TestMain.