Testing Guide
Learn how to test Tracks-generated applications at every layer.
Testing Philosophy
Tracks-generated code is designed for testing:
- Dependency injection makes every component mockable
- Interface-based design decouples layers
- Mockery integration auto-generates mocks from interfaces
- No global state means tests are isolated
Every layer has its own testing strategy.
Test Types
Unit Tests
Fast, isolated tests with no external dependencies:
- Mock all dependencies
- Test business logic in isolation
- Run with
-shortflag - Use the race detector (
-race)
go test -v -race -short ./...
Integration Tests
Test component integration without mocking:
- Real database (transaction rollback for cleanup)
- Test SQL queries work correctly
- Verify layers communicate properly
- Slower than unit tests
go test -v ./tests/integration/...
Mock Generation
Tracks uses mockery to generate mocks from interfaces.
Configuration
Mocks are configured in .mockery.yaml:
with-expecter: true
outpkg: mocks
output: tests/mocks
packages:
github.com/youruser/yourproject/internal/interfaces:
interfaces:
UserService:
UserRepository:
HealthService:
HealthRepository:
Generating Mocks
# Generate all mocks
make generate-mocks
# Or use mockery directly
go tool mockery
This creates mocks in tests/mocks/:
tests/mocks/
├── mock_UserService.go
├── mock_UserRepository.go
├── mock_HealthService.go
└── mock_HealthRepository.go
Using Mocks in Tests
import (
"testing"
"github.com/stretchr/testify/mock"
"github.com/youruser/yourproject/tests/mocks"
)
func TestUserService_GetByID(t *testing.T) {
mockRepo := mocks.NewMockUserRepository(t)
mockRepo.EXPECT().
FindByID(mock.Anything, "123").
Return(&interfaces.User{
ID: "123",
Name: "John Doe",
}, nil).
Once()
svc := NewService(mockRepo)
user, err := svc.GetByID(context.Background(), "123")
assert.NoError(t, err)
assert.Equal(t, "John Doe", user.Name)
}
Testing Services
Services contain business logic - mock the repository.
Example: Testing user creation with validation
func TestService_Create(t *testing.T) {
t.Run("success", func(t *testing.T) {
mockRepo := mocks.NewMockUserRepository(t)
mockRepo.EXPECT().
Insert(mock.Anything, mock.MatchedBy(func(u *interfaces.User) bool {
return u.Name == "Jane Doe" && u.Email == "jane@example.com"
})).
Return(nil).
Once()
svc := NewService(mockRepo)
user, err := svc.Create(context.Background(), "Jane Doe", "jane@example.com")
assert.NoError(t, err)
assert.NotEmpty(t, user.ID)
assert.Equal(t, "Jane Doe", user.Name)
})
t.Run("validation error - empty name", func(t *testing.T) {
mockRepo := mocks.NewMockUserRepository(t)
svc := NewService(mockRepo)
_, err := svc.Create(context.Background(), "", "jane@example.com")
assert.ErrorIs(t, err, ErrInvalidInput)
})
t.Run("repository error", func(t *testing.T) {
mockRepo := mocks.NewMockUserRepository(t)
mockRepo.EXPECT().
Insert(mock.Anything, mock.Anything).
Return(errors.New("db error")).
Once()
svc := NewService(mockRepo)
_, err := svc.Create(context.Background(), "Jane Doe", "jane@example.com")
assert.Error(t, err)
assert.Contains(t, err.Error(), "inserting user")
})
}
Key Points:
- Test happy path and error cases
- Mock repository calls
- Verify business logic (validation, transformations)
- Don't test the database
Testing Repositories
Repositories wrap SQLC-generated code - use integration tests with real database.
Example: Integration test for user repository
func TestRepository_Insert(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
db := setupTestDB(t)
defer db.Close()
tx, err := db.BeginTx(context.Background(), nil)
require.NoError(t, err)
defer tx.Rollback()
repo := NewRepository(tx)
user := &interfaces.User{
ID: uuid.New().String(),
Name: "Test User",
Email: "test@example.com",
}
err = repo.Insert(context.Background(), user)
assert.NoError(t, err)
found, err := repo.FindByID(context.Background(), user.ID)
assert.NoError(t, err)
assert.Equal(t, user.Name, found.Name)
assert.Equal(t, user.Email, found.Email)
}
func setupTestDB(t *testing.T) *sql.DB {
db, err := sql.Open("sqlite3", ":memory:")
require.NoError(t, err)
migrationFiles, err := filepath.Glob("../../internal/db/migrations/*.sql")
require.NoError(t, err)
for _, file := range migrationFiles {
sql, err := os.ReadFile(file)
require.NoError(t, err)
_, err = db.Exec(string(sql))
require.NoError(t, err)
}
return db
}
Key Points:
- Use real database (in-memory SQLite or test container)
- Transaction rollback for cleanup
- Test SQLC queries work correctly
- Skip with
testing.Short()
Testing Handlers
Handlers orchestrate services - mock services, test HTTP concerns.
Example: Testing user creation handler
func TestUserHandler_Create(t *testing.T) {
t.Run("success", func(t *testing.T) {
mockService := mocks.NewMockUserService(t)
mockService.EXPECT().
Create(mock.Anything, "johndoe", "john@example.com").
Return(&interfaces.User{
ID: "123",
Username: "johndoe",
Email: "john@example.com",
}, nil).
Once()
handler := NewUserHandler(mockService)
form := url.Values{}
form.Add("username", "johndoe")
form.Add("email", "john@example.com")
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handler.Create(w, req)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/u/johndoe", w.Header().Get("Location"))
})
t.Run("invalid form data", func(t *testing.T) {
mockService := mocks.NewMockUserService(t)
handler := NewUserHandler(mockService)
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(""))
w := httptest.NewRecorder()
handler.Create(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("service error", func(t *testing.T) {
mockService := mocks.NewMockUserService(t)
mockService.EXPECT().
Create(mock.Anything, mock.Anything, mock.Anything).
Return(nil, errors.New("service error")).
Once()
handler := NewUserHandler(mockService)
form := url.Values{}
form.Add("username", "johndoe")
form.Add("email", "john@example.com")
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handler.Create(w, req)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
})
}
Key Points:
- Mock all service dependencies
- Use
httptest.NewRequestandhttptest.NewRecorder - Test HTTP status codes
- Test request/response marshaling
- Don't test business logic (that's in service tests)
Testing Templ Components
Tracks uses templ for type-safe HTML templating. Testing templ components focuses on accessible queries that find elements by semantic meaning (roles, labels, text content) rather than brittle selectors (IDs, classes).
For comprehensive templ testing guidance, see the official templ testing guide.
Testing Philosophy
- Accessible queries - Find elements by text, role, or semantic attributes
- Maintainable tests - Tests survive styling changes (class names, IDs)
- User-centric - Test what users see, not implementation details
- goquery for parsing - jQuery-like API for Go
Testing Individual Components
Test components in isolation by rendering to a buffer and parsing with goquery.
Example: Testing a navigation component
package components
import (
"bytes"
"context"
"testing"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"yourproject/internal/http/views/components"
)
func TestNav_Render(t *testing.T) {
// Render component to buffer
var buf bytes.Buffer
err := components.Nav().Render(context.Background(), &buf)
require.NoError(t, err)
// Parse HTML with goquery
doc, err := goquery.NewDocumentFromReader(&buf)
require.NoError(t, err)
// Use accessible queries - find by text content
homeLink := doc.Find("a:contains('Home')").First()
assert.Equal(t, 1, homeLink.Length(), "should have Home link")
assert.Equal(t, "/", homeLink.AttrOr("href", ""), "Home link should point to /")
aboutLink := doc.Find("a:contains('About')").First()
assert.Equal(t, 1, aboutLink.Length(), "should have About link")
assert.Equal(t, "/about", aboutLink.AttrOr("href", ""), "About link should point to /about")
// Verify semantic structure
nav := doc.Find("nav")
assert.Equal(t, 1, nav.Length(), "should have nav element")
}
Example: Testing a meta component with table-driven tests
func TestMeta_Render(t *testing.T) {
tests := []struct {
name string
title string
description string
wantTitle string
wantDesc string
}{
{
name: "renders all metadata",
title: "Test Page",
description: "A test page description",
wantTitle: "Test Page",
wantDesc: "A test page description",
},
{
name: "handles empty description",
title: "Test Page",
description: "",
wantTitle: "Test Page",
wantDesc: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
err := components.Meta(tt.title, tt.description).Render(context.Background(), &buf)
require.NoError(t, err)
doc, err := goquery.NewDocumentFromReader(&buf)
require.NoError(t, err)
// Find title by element name
title := doc.Find("title").Text()
assert.Equal(t, tt.wantTitle, title)
// Find meta description by attribute
metaDesc := doc.Find("meta[name='description']").AttrOr("content", "")
assert.Equal(t, tt.wantDesc, metaDesc)
})
}
}
Testing Pages with Layout
Test full pages including base layout to verify complete rendering.
Example: Testing home page with layout
func TestHomePage_Render(t *testing.T) {
var buf bytes.Buffer
err := pages.Home().Render(context.Background(), &buf)
require.NoError(t, err)
doc, err := goquery.NewDocumentFromReader(&buf)
require.NoError(t, err)
// Verify page title
title := doc.Find("title").Text()
assert.Contains(t, title, "Home")
// Verify main heading by text content
h1 := doc.Find("h1:contains('Welcome')").First()
assert.Equal(t, 1, h1.Length(), "should have welcome heading")
// Verify navigation is present
homeLink := doc.Find("a:contains('Home')")
assert.GreaterOrEqual(t, homeLink.Length(), 1, "should have Home link in nav")
// Verify footer is present
footer := doc.Find("footer")
assert.Equal(t, 1, footer.Length(), "should have footer")
}
Testing Error Pages
Use table-driven tests for multiple error scenarios.
Example: Testing 404 and 500 error pages
func TestErrorPages_Render(t *testing.T) {
tests := []struct {
name string
statusCode int
wantHeading string
wantStatusText string
}{
{
name: "404 Not Found",
statusCode: 404,
wantHeading: "404",
wantStatusText: "Not Found",
},
{
name: "500 Internal Server Error",
statusCode: 500,
wantHeading: "500",
wantStatusText: "Internal Server Error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
err := pages.Error(tt.statusCode).Render(context.Background(), &buf)
require.NoError(t, err)
doc, err := goquery.NewDocumentFromReader(&buf)
require.NoError(t, err)
// Find heading by text content
heading := doc.Find(fmt.Sprintf("h1:contains('%s')", tt.wantHeading)).First()
assert.Equal(t, 1, heading.Length(), "should have error code heading")
// Find status text
assert.Contains(t, buf.String(), tt.wantStatusText, "should contain status text")
})
}
}
Testing HTMX Partial Rendering
Test both full page and HTMX partial responses.
Example: Integration test with HTMX header detection
func TestPages_HTMXPartials(t *testing.T) {
tests := []struct {
name string
path string
headers map[string]string
isHTMXPartial bool
wantContains []string
wantNotContains []string
}{
{
name: "full page render without HTMX header",
path: "/",
wantContains: []string{"<html", "</html>", "<h1>Welcome"},
},
{
name: "partial render with HTMX header",
path: "/",
headers: map[string]string{"HX-Request": "true"},
isHTMXPartial: true,
wantContains: []string{"<h1>Welcome"},
wantNotContains: []string{"<html", "</html>"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
// Add HTMX header if testing partial
for key, value := range tt.headers {
req.Header.Set(key, value)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
body := rec.Body.String()
// For partials, use string matching
if tt.isHTMXPartial {
for _, contains := range tt.wantContains {
assert.Contains(t, body, contains)
}
for _, notContains := range tt.wantNotContains {
assert.NotContains(t, body, notContains)
}
return
}
// For full pages, use goquery
doc, err := goquery.NewDocumentFromReader(rec.Body)
require.NoError(t, err)
for _, contains := range tt.wantContains {
assert.Contains(t, body, contains)
}
})
}
}
Accessible Query Patterns with goquery
Good patterns (accessible, maintainable):
// Find by text content
doc.Find("a:contains('Home')")
doc.Find("h1:contains('Welcome')")
doc.Find("button:contains('Submit')")
// Find by semantic element
doc.Find("nav")
doc.Find("footer")
doc.Find("main")
// Find by attribute value
doc.Find("meta[name='description']")
doc.Find("a[href='/about']")
doc.Find("input[type='email']")
// Find by ARIA attributes (accessibility)
doc.Find("[aria-label='Main navigation']")
doc.Find("[role='alert']")
// Combining selectors
doc.Find("nav a:contains('Home')") // Home link inside nav
Bad patterns (brittle, implementation-dependent):
// ❌ Avoid - CSS class names change
doc.Find(".btn-primary")
doc.Find(".nav-link")
// ❌ Avoid - IDs are implementation details
doc.Find("#submit-button")
doc.Find("#main-nav")
// ❌ Avoid - Position-dependent selectors
doc.Find("div > div > a") // Fragile to markup changes
goquery API Reference
Common goquery methods for testing:
// Selection
doc.Find("selector") // Find by CSS selector (supports CSS3 selectors via cascadia)
selection.First() // First matching element
selection.Last() // Last matching element
selection.Eq(index) // Element at index
// Content
selection.Text() // Text content
selection.Html() // HTML content
selection.AttrOr("name", "default") // Attribute value with default
// Traversal
selection.Children() // Direct children
selection.Parent() // Parent element
selection.Siblings() // Sibling elements
// Filtering
selection.Length() // Number of elements
selection.Has("selector") // Filter by descendant
selection.Filter("selector") // Filter selection
// Assertions
assert.Equal(t, 1, selection.Length())
assert.Contains(t, selection.Text(), "expected")
assert.Equal(t, "value", selection.AttrOr("href", ""))
Best Practices for Templ Testing
DO:
- ✅ Use accessible queries (text, semantic elements, ARIA)
- ✅ Test what users see, not implementation
- ✅ Use table-driven tests for multiple scenarios
- ✅ Test both full pages and HTMX partials
- ✅ Keep component tests focused and fast
- ✅ Verify semantic HTML structure
- ✅ Use
requirefor setup/parsing failures (stops test on failure) - ✅ Use
assertfor test assertions (continues on failure, shows all failures)
DON'T:
- ❌ Query by CSS class names (they change with styling)
- ❌ Query by element IDs (implementation details)
- ❌ Use position-dependent selectors (fragile)
- ❌ Test inline styles or exact HTML structure
- ❌ Test third-party component internals
- ❌ Skip error page testing
Performance Tip:
For simple string presence checks (especially in HTMX partials), use strings.Contains() for better performance. Use goquery when you need to traverse the DOM or query by complex selectors. The HTMX partial test example above (lines 543-551) demonstrates this pattern.
Integration Tests for Pages
Full integration tests verify pages render correctly via HTTP.
Example: From tests/integration/pages_test.go
func TestPages_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
logger := logging.NewLogger("test")
cfg := &config.ServerConfig{Port: ":8080"}
mockHealthService := mocks.NewMockHealthService(t)
server := httpserver.NewServer(cfg, logger).
WithHealthService(mockHealthService).
RegisterRoutes()
tests := []struct {
name string
path string
wantStatus int
}{
{
name: "should render home page",
path: "/",
wantStatus: http.StatusOK,
},
{
name: "should render about page",
path: "/about",
wantStatus: http.StatusOK,
},
{
name: "should return 404 for nonexistent route",
path: "/nonexistent",
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
rec := httptest.NewRecorder()
server.ServeHTTP(rec, req)
assert.Equal(t, tt.wantStatus, rec.Code)
if rec.Code == http.StatusOK {
doc, err := goquery.NewDocumentFromReader(rec.Body)
require.NoError(t, err)
// Verify common elements with accessible queries
assert.GreaterOrEqual(t, doc.Find("a:contains('Home')").Length(), 1)
assert.GreaterOrEqual(t, doc.Find("a:contains('About')").Length(), 1)
assert.Equal(t, 1, doc.Find("title").Length())
}
})
}
}
Testing Cross-Domain Handlers
Handlers using multiple services need multiple mocks.
Example: Dashboard handler
func TestDashboardHandler_Get(t *testing.T) {
mockUserService := mocks.NewMockUserService(t)
mockPostService := mocks.NewMockPostService(t)
mockStatsService := mocks.NewMockStatsService(t)
mockUserService.EXPECT().
GetCurrent(mock.Anything).
Return(&interfaces.User{ID: "123", Name: "John"}, nil).
Once()
mockPostService.EXPECT().
ListByAuthor(mock.Anything, "123", 5).
Return([]*interfaces.Post{
{ID: "post1", Title: "Hello"},
}, nil).
Once()
mockStatsService.EXPECT().
GetForUser(mock.Anything, "123").
Return(&interfaces.Stats{PostCount: 42}, nil).
Once()
handler := NewDashboardHandler(mockUserService, mockPostService, mockStatsService)
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
w := httptest.NewRecorder()
handler.Get(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
assert.Contains(t, w.Body.String(), "John")
assert.Contains(t, w.Body.String(), "Hello")
}
Testing Middleware
Middleware tests verify the middleware chain behavior.
Example: Testing logging middleware
func TestLoggingMiddleware(t *testing.T) {
mockLogger := mocks.NewMockLogger(t)
mockLogger.EXPECT().
Info("request started", mock.Anything).
Once()
mockLogger.EXPECT().
Info("request completed", mock.Anything).
Once()
mw := NewLogging(mockLogger)
called := false
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
wrapped := mw(testHandler)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
w := httptest.NewRecorder()
wrapped.ServeHTTP(w, req)
assert.True(t, called, "handler should have been called")
assert.Equal(t, http.StatusOK, w.Code)
}
Test Structure
Directory Layout
internal/
├── domain/
│ └── users/
│ ├── service.go
│ ├── service_test.go # Unit tests
│ ├── repository.go
│ └── repository_test.go # Integration tests
├── http/
│ └── handlers/
│ ├── user.go
│ └── user_test.go # Unit tests
tests/
├── mocks/ # Generated mocks
│ ├── mock_UserService.go
│ └── mock_UserRepository.go
└── integration/ # E2E integration tests
└── users_test.go
Test File Naming
- Unit tests:
*_test.gonext to source file - Integration tests:
tests/integration/*_test.go - Build tag for slow tests:
//go:build integration
Test Function Naming
func TestServiceName_MethodName(t *testing.T) {}
func TestHandlerName_HTTPMethod(t *testing.T) {}
func TestRepositoryName_MethodName(t *testing.T) {}
Running Tests
All Unit Tests
make test
# or
go test -v -race -short ./...
Integration Tests
go test -v ./tests/integration/...
Specific Package
go test -v ./internal/domain/users/...
With Coverage
make test-coverage
Watch Mode (with Air)
# In .air.toml
cmd = "go test -v ./..."
Test Coverage
Generated projects include coverage reports:
make test-coverage
This generates:
coverage-unit.out- Unit test coveragecoverage-integration.out- Integration test coveragecoverage.html- HTML coverage report
View coverage:
go tool cover -html=coverage.html
Best Practices
DO
- ✅ Write tests before implementation (TDD)
- ✅ Test happy path and error cases
- ✅ Use table-driven tests for multiple scenarios
- ✅ Mock all external dependencies
- ✅ Use
t.Helper()in test helper functions - ✅ Run tests with race detector
- ✅ Use integration tests for repositories
- ✅ Keep tests focused and isolated
- ✅ Regenerate mocks after interface changes
DON'T
- ❌ Test implementation details
- ❌ Use real database in unit tests
- ❌ Skip error case testing
- ❌ Write flaky tests (time-dependent, order-dependent)
- ❌ Test third-party code (SQLC, standard library)
- ❌ Commit without running tests
- ❌ Ignore test failures
- ❌ Write tests without assertions
Table-Driven Tests
Use table-driven tests for multiple scenarios:
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
wantErr bool
}{
{
name: "valid email",
email: "user@example.com",
wantErr: false,
},
{
name: "missing @",
email: "userexample.com",
wantErr: true,
},
{
name: "missing domain",
email: "user@",
wantErr: true,
},
{
name: "empty",
email: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateEmail(tt.email)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
Next Steps
- Architecture Overview - Core principles
- Layer Guide - Deep dive on each layer
- Patterns - Common patterns for extending
See Also
- CLI: tracks new - Creating projects
- testify Documentation - Assertion library
- mockery Documentation - Mock generation
- templ Testing Guide - Official templ testing docs
- goquery Documentation - jQuery-like DOM parsing for Go
- CSS Selectors Reference - CSS selector syntax supported by goquery