| name | golang-testing-patterns |
| description | Modern Go testing patterns including table-driven tests, subtests, test organization, and best practices. Use when writing or refactoring tests, implementing test coverage, or when the user asks about Go testing approaches. |
Go Testing Patterns
Modern testing patterns for Go following 2025-2026 best practices.
Table-Driven Tests
The idiomatic Go approach for comprehensive testing.
Basic structure:
func TestFeature(t *testing.T) {
tests := []struct {
name string
input string
expected int
wantErr bool
}{
{
name: "valid input",
input: "hello",
expected: 5,
wantErr: false,
},
{
name: "empty input",
input: "",
expected: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ProcessString(tt.input)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
}
Using Subtests with t.Run()
Benefits:
- Clear failure messages showing which case failed
- Can run specific tests:
go test -run TestFeature/valid_input
- Parallel execution support
Pattern:
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
})
}
Test Organization
File structure:
- Tests live alongside code:
service.go → service_test.go
- Integration tests in separate package:
service_integration_test.go
- Shared test utilities:
test/util/ or testutil/
Test function naming:
func TestSessionService_CreateSession(t *testing.T) {}
func TestSessionService_CreateSession_ValidationError(t *testing.T) {}
func TestSessionService_Integration(t *testing.T) {}
Setup and Teardown
Using t.Cleanup():
func TestWithCleanup(t *testing.T) {
db := setupTestDB(t)
t.Cleanup(func() {
db.Close()
})
}
Setup once for all subtests:
func TestSuite(t *testing.T) {
db := setupTestDB(t)
t.Cleanup(func() { db.Close() })
t.Run("test1", func(t *testing.T) {
})
t.Run("test2", func(t *testing.T) {
})
}
Testing Database Operations
Transaction-based isolation:
func TestDatabaseOperation(t *testing.T) {
db := setupTestDB(t)
tests := []struct {
name string
}{
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tx, err := db.Begin()
require.NoError(t, err)
t.Cleanup(func() { _ = tx.Rollback() })
})
}
}
Error Testing Patterns
Check error occurrence:
Use the github.com/stretchr/testify/require package
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
Check specific error:
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
Check error message — prefer exact match:
Use Equal / direct comparison for error messages you control. Only fall back to
Contains when the message includes third-party or runtime text you don't own.
assert.Equal(t, "missing required field 'name'", err.Error())
var loadErr *LoadError
require.ErrorAs(t, err, &loadErr)
assert.Equal(t, "missing required field 'name'", loadErr.Err.Error())
assert.Contains(t, err.Error(), "invalid YAML")
Test Helpers
Creating test data:
func newTestSession(t *testing.T, overrides ...func(*ent.AlertSession)) *ent.AlertSession {
t.Helper()
session := &ent.AlertSession{
ID: "test-123",
Status: "pending",
StartedAt: time.Now(),
}
for _, override := range overrides {
override(session)
}
return session
}
session := newTestSession(t, func(s *ent.AlertSession) {
s.Status = "completed"
})
Mocking and Interfaces
Interface-based testing:
type SessionRepository interface {
GetByID(ctx context.Context, id string) (*Session, error)
}
type mockSessionRepo struct {
sessions map[string]*Session
}
func (m *mockSessionRepo) GetByID(ctx context.Context, id string) (*Session, error) {
s, ok := m.sessions[id]
if !ok {
return nil, ErrNotFound
}
return s, nil
}
Testing Patterns for TARSy
Service layer tests:
func TestSessionService_CreateSession(t *testing.T) {
client, err := test.SetupTestDatabase(t)
require.NoError(t, err)
service := services.NewSessionService(client)
tests := []struct {
name string
req models.CreateSessionRequest
wantErr bool
wantErrMsg string
}{
{
name: "valid session",
req: models.CreateSessionRequest{
SessionID: "sess-123",
},
wantErr: false,
},
{
name: "missing session ID",
req: models.CreateSessionRequest{
SessionID: "",
},
wantErr: true,
wantErrMsg: "session_id is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := t.Context()
session, err := service.CreateSession(ctx, tt.req)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
if tt.wantErrMsg != "" {
if err.Error() != tt.wantErrMsg {
t.Errorf("error = %q, want %q", err.Error(), tt.wantErrMsg)
}
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if session.ID != tt.req.SessionID {
t.Errorf("session ID = %q, want %q", session.ID, tt.req.SessionID)
}
})
}
}
Quick Reference
When to use table-driven tests:
- Multiple similar test cases with different inputs/outputs
- Testing edge cases and validation
- Any test with more than 2-3 cases
When to skip table-driven tests:
- Single, unique test case
- Complex setup that varies significantly between cases
- Integration tests with sequential dependencies
Parallel testing:
t.Parallel()
Assertions:
- Use
t.Errorf() for non-fatal assertions
- Use
t.Fatalf() when continuing would panic or is meaningless
- Always include both
got and want in error messages
- Prefer
Equal over Contains for values you control (catches regressions, documents the exact contract)
- Reserve
Contains for third-party/runtime strings you don't own