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}
}

Gotchas

  • req.SetPathValue() is new in Go 1.22. Use it in tests to set path parameters.
  • Table-driven tests scale. Add cases, not test functions.
  • Playwright webServer starts your Go app automatically. Set reuseExistingServer: true for local dev.
  • Clean up test data between tests. Use transactions that rollback, or truncate tables in TestMain.