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 test command
  • 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: true for local dev.
  • Clean up test data between tests. Use transactions that rollback, or truncate tables in TestMain.