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
webServerstarts your Go app automatically. SetreuseExistingServer: truefor local dev. - Clean up test data between tests. Use transactions that rollback, or truncate tables in
TestMain.