| name | go-testing |
| description | Write unit and integration tests for Go services. Use when creating, editing, or updating tests, test helpers, mocks, fuzz tests, or benchmarks in Go projects. |
Go Testing
Guidelines for writing tests in Go services following
established patterns.
[!NOTE]
go-conventions is the authority for Go
style and linter rules (noctx, scopeguard, mandatory test doc
comments). Load it alongside this skill; where the two overlap, it
wins. This skill covers test structure and templates.
Instructions
When asked to write unit tests, ask the user if they prefer:
- Table-driven tests - test cases defined in a struct slice
- F-tests - helper function
f with explicit named subtests
If not specified, default to table-driven tests.
Unit Tests
Unit tests live alongside source code (*_test.go) and run with
make test or go test ./....
Table-Driven Tests
Use table-driven tests with t.Run() for clear, maintainable
test cases:
func TestFunctionName(t *testing.T) {
tests := []struct {
name string
input string
expected string
expectErr bool
errContains string
}{
{
name: "valid input",
input: "hello",
expected: "HELLO",
},
{
name: "empty input returns error",
input: "",
expectErr: true,
errContains: "input cannot be empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := FunctionName(tt.input)
if tt.expectErr {
if err == nil {
t.Fatal("expected an error, but got nil")
}
if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("expected error to contain %q, got %q",
tt.errContains, err.Error())
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != tt.expected {
t.Errorf("expected %q, got %q",
tt.expected, result)
}
})
}
}
F-Tests (Alternative Style)
F-tests hoist assertion logic into a helper function f, making
test cases more readable. This style works well when you want
explicit, named subtests without the verbosity of table structs:
func TestSomeFuncWithSubtests(t *testing.T) {
f := func(t *testing.T, input, expected string) {
t.Helper()
output := SomeFunc(input)
if output != expected {
t.Fatalf("unexpected output; got %q; want %q",
output, expected)
}
}
t.Run("converts_to_uppercase", func(t *testing.T) {
f(t, "hello", "HELLO")
})
t.Run("handles_empty_string", func(t *testing.T) {
f(t, "", "")
})
t.Run("preserves_numbers", func(t *testing.T) {
f(t, "abc123", "ABC123")
})
}
You can also combine f-tests with table-driven tests for the
best of both approaches:
func TestThing_Success(t *testing.T) {
f := func(t *testing.T, input1, input2 string, expected int) {
t.Helper()
result := Thing(input1, input2)
if result != expected {
t.Fatalf("Thing(%q, %q) = %d; want %d",
input1, input2, result, expected)
}
}
tests := []struct {
name string
input1 string
input2 string
expected int
}{
{name: "both_empty", input1: "", input2: "",
expected: 0},
{name: "first_only", input1: "foo", input2: "",
expected: 3},
{name: "both_set", input1: "foo", input2: "bar",
expected: 6},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f(t, tt.input1, tt.input2, tt.expected)
})
}
}
Test Helpers
Mark helper functions with t.Helper() so errors report the
correct line:
func assertStatusCode(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("expected status %d, got %d", want, got)
}
}
func newTestLogger(t *testing.T) *slog.Logger {
t.Helper()
var buf bytes.Buffer
return slog.New(slog.NewJSONHandler(&buf, nil))
}
HTTP Handler Tests
Use httptest for testing HTTP handlers:
func TestHandler(t *testing.T) {
tests := []struct {
name string
method string
path string
body string
expectedStatus int
}{
{
name: "GET returns 200",
method: http.MethodGet,
path: "/api/v1/resource",
expectedStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var reqBody io.Reader
if tt.body != "" {
reqBody = bytes.NewBufferString(tt.body)
}
req := httptest.NewRequestWithContext(
t.Context(), tt.method, tt.path, reqBody)
req.Header.Set(
"Content-Type", "application/json")
rr := httptest.NewRecorder()
handler := NewHandler()
handler.ServeHTTP(rr, req)
if rr.Code != tt.expectedStatus {
t.Errorf("expected status %d, got %d",
tt.expectedStatus, rr.Code)
}
})
}
}
Mocks
Use testify/mock. Embed mock.Mock, record calls with m.Called,
and add a compile-time interface check:
type MockClient struct {
mock.Mock
}
var _ Client = (*MockClient)(nil)
func (m *MockClient) DoSomething(ctx context.Context, id string) error {
args := m.Called(ctx, id)
return args.Error(0)
}
Set expectations in the test and assert them:
m := &MockClient{}
m.On("DoSomething", mock.Anything, "abc").Return(nil)
m.AssertExpectations(t)
Assertions
Prefer standard library assertions for unit tests. Use
testify/assert for complex assertions or integration tests:
if result != expected {
t.Errorf("expected %v, got %v", expected, result)
}
import "github.com/stretchr/testify/assert"
assert.Equal(t, expected, result, "values should match")
assert.Contains(t, haystack, needle,
"should contain substring")
assert.NotNil(t, obj, "object should not be nil")
Integration Tests
Integration tests use the e2e build tag and live in the e2e/
directory. Run with make test-integration.
Build Tag
All integration test files must start with:
package e2e_test
Test Structure
Integration tests validate complete workflows:
package e2e_test
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
const (
baseURL = "http://localhost:8080"
apiKey = "test-api-key"
)
var client = &http.Client{Timeout: 10 * time.Second}
func TestE2EWorkflow(t *testing.T) {
t.Run("Scenario: Complete CRUD workflow",
testCRUDWorkflow)
t.Run("Scenario: Error handling",
testErrorHandling)
}
Fuzz Tests
Fuzz tests discover edge cases through randomized inputs. Use
for security-critical validation:
func FuzzValidatePath(f *testing.F) {
f.Add("/")
f.Add("/api/v1")
f.Add("/users/123")
f.Add("")
f.Add("no-leading-slash")
f.Add("/path/../traversal")
f.Fuzz(func(t *testing.T, path string) {
if !utf8.ValidString(path) {
t.Skip("skipping invalid UTF-8")
}
result := ValidatePath(path)
if result {
if !strings.HasPrefix(path, "/") {
t.Errorf(
"valid path %q should start with /",
path)
}
}
})
}
Run fuzz tests: go test -fuzz=FuzzValidatePath -fuzztime=30s ./...
Benchmarks
Use benchmarks to measure and track performance:
func BenchmarkOperation(b *testing.B) {
data := prepareTestData()
for b.Loop() {
_ = Operation(data)
}
}
func BenchmarkOperationParallel(b *testing.B) {
data := prepareTestData()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = Operation(data)
}
})
}
Run benchmarks: go test -bench=. -benchmem ./...
Running Tests
make test
make test-integration
go test -v -run TestFunctionName ./internal/pkg/...
go test -race ./...
go test -cover ./...