| name | go-testing-advanced |
| description | Advanced Go testing — interface-based mocking, benchmarks (basic, size-parametrized, allocation), fuzzing (Go 1.18+), test coverage, HTTP handler testing with httptest, best practices, and CI/CD integration. Extends go-testing. |
Go Testing — Advanced Patterns
This skill extends go-testing with mocking, benchmarks, fuzzing, HTTP testing, and CI/CD.
When to Activate
- Writing interface-based mocks without external libraries
- Benchmarking performance-critical code
- Using Go 1.18+ fuzzing for input validation
- Testing HTTP handlers with
net/http/httptest
- Setting up CI/CD test pipelines
- Comparing benchmark results across commits using
benchstat to detect performance regressions
- Adding a fuzz corpus and property-based assertions to a parser or input validation function
- Configuring a GitHub Actions workflow that enforces Go test coverage thresholds on every pull request
Interface-Based Mocking
The idiomatic Go approach: define a minimal interface, write a fake that implements it, inject at the call site.
Defining the Interface
package user
type EmailSender interface {
Send(to, subject, body string) error
}
type UserRepository interface {
FindByID(id int) (*User, error)
Save(u *User) error
}
type UserService struct {
repo UserRepository
mailer EmailSender
}
func NewUserService(repo UserRepository, mailer EmailSender) *UserService {
return &UserService{repo: repo, mailer: mailer}
}
func (s *UserService) Register(name, email string) (*User, error) {
u := &User{Name: name, Email: email}
if err := s.repo.Save(u); err != nil {
return nil, err
}
_ = s.mailer.Send(email, "Welcome", "Thanks for signing up!")
return u, nil
}
Writing the Fake
package user_test
type fakeMailer struct {
calls []struct{ to, subject, body string }
err error
}
func (m *fakeMailer) Send(to, subject, body string) error {
m.calls = append(m.calls, struct{ to, subject, body string }{to, subject, body})
return m.err
}
type fakeRepo struct {
saved []*User
err error
}
func (r *fakeRepo) FindByID(id int) (*User, error) {
for _, u := range r.saved {
if u.ID == id {
return u, nil
}
}
return nil, ErrNotFound
}
func (r *fakeRepo) Save(u *User) error {
if r.err != nil {
return r.err
}
u.ID = len(r.saved) + 1
r.saved = append(r.saved, u)
return nil
}
func TestUserService_Register(t *testing.T) {
tests := []struct {
name string
repoErr error
mailerErr
wantEmail
wantErr
}{
{, , , , },
{, ErrDB, , , },
{, , ErrMail, , },
}
_, tt := tests {
t.Run(tt.name, {
repo := &fakeRepo{err: tt.repoErr}
mailer := &fakeMailer{err: tt.mailerErr}
svc := NewUserService(repo, mailer)
user, err := svc.Register(, )
tt.wantErr {
err == {
t.Error()
}
}
err != {
t.Fatalf(, err)
}
user.Name != {
t.Errorf(, user.Name, )
}
tt.wantEmail && (mailer.calls) == {
t.Error()
}
})
}
}
Spy Pattern (Record Calls)
type spyLogger struct {
mu sync.Mutex
entries []string
}
func (l *spyLogger) Log(msg string) {
l.mu.Lock()
defer l.mu.Unlock()
l.entries = append(l.entries, msg)
}
func (l *spyLogger) Contains(s string) bool {
l.mu.Lock()
defer l.mu.Unlock()
for _, e := range l.entries {
if strings.Contains(e, s) {
return true
}
}
return false
}
Benchmarks
Basic Benchmark
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
Reset and Stop Timers
func BenchmarkWithSetup(b *testing.B) {
data := generateLargeDataset()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Process(data)
}
}
func BenchmarkWithTeardown(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer()
data := prepareData()
b.StartTimer()
Process(data)
}
}
Size-Parametrized Benchmark
func BenchmarkSort(b *testing.B) {
sizes := []int{10, 100, 1000, 10000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
data := make([]int, size)
for i := range data {
data[i] = rand.Intn(size)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
b.StopTimer()
input := make([]int, len(data))
copy(input, data)
b.StartTimer()
sort.Ints(input)
}
})
}
}
Allocation Benchmark
func BenchmarkAllocations(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
result := buildString("hello", "world")
_ = result
}
}
Running Benchmarks
go test -bench=.
go test -bench=BenchmarkSort
go test -bench=. -benchmem
go test -bench=. -benchtime=5s
go test -bench=. -benchtime=10x
go test -bench=. -count=5 > old.txt
go test -bench=. -count=5 > new.txt
benchstat old.txt new.txt
Fuzzing (Go 1.18+)
package parser_test
import (
"testing"
"unicode/utf8"
)
func FuzzParseInput(f *testing.F) {
f.Add("hello world")
f.Add("")
f.Add("special: !@#$%")
f.Add("unicode: 日本語")
f.Fuzz(func(t *testing.T, input string) {
result, err := ParseInput(input)
if err == nil {
if !utf8.ValidString(result) {
t.Errorf("ParseInput(%q) returned non-UTF-8 output", input)
}
}
if len(result) > len(input) {
t.Errorf("output longer than input: %d > %d", len(result), len(input))
}
})
}
go test -run FuzzParseInput
go test -fuzz FuzzParseInput -fuzztime=30s
go test -fuzz FuzzParseInput
HTTP Handler Testing with httptest
Testing Individual Handlers
package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestGetUserHandler(t *testing.T) {
tests := []struct {
name string
userID string
wantStatus int
wantName string
}{
{"existing user", "1", http.StatusOK, "Alice"},
{"not found", "999", http.StatusNotFound, ""},
{"invalid id", "abc", http.StatusBadRequest, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users/"+tt.userID, nil)
w := httptest.NewRecorder()
handler := NewUserHandler(fakeUserRepo)
handler.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("status = %d; want %d", w.Code, tt.wantStatus)
}
if tt.wantName != "" {
var user User
json.NewDecoder(w.Body).Decode(&user)
if user.Name != tt.wantName {
t.Errorf("name = %q; want %q", user.Name, tt.wantName)
}
}
})
}
}
func TestCreateUserHandler {
body :=
req := httptest.NewRequest(http.MethodPost, , strings.NewReader(body))
req.Header.Set(, )
w := httptest.NewRecorder()
handler := NewUserHandler(fakeUserRepo)
handler.ServeHTTP(w, req)
w.Code != http.StatusCreated {
t.Errorf(, w.Code, http.StatusCreated)
}
created User
json.NewDecoder(w.Body).Decode(&created)
created.ID == {
t.Error()
}
}
Testing with a Real Server
func TestWithServer(t *testing.T) {
router := setupRouter()
server := httptest.NewServer(router)
defer server.Close()
resp, err := http.Get(server.URL + "/health")
if err != nil {
t.Fatalf("GET /health: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d; want %d", resp.StatusCode, http.StatusOK)
}
}
Test Coverage
go test ./... -cover
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out
go tool cover -func=coverage.out
go test ./... -cover -coverprofile=coverage.out
go tool cover -func=coverage.out | grep total | awk '{if ($3 < 80) exit 1}'
Best Practices
Test File Conventions
package mypackage_test
package mypackage
func TestParseDate_ValidFormat(t *testing.T) {}
func TestParseDate_InvalidMonth(t *testing.T) {}
func TestParseDate_LeapYear(t *testing.T) {}
Test Helper Best Practices
func assertJSON(t *testing.T, body *bytes.Buffer, want interface{}) {
t.Helper()
var got interface{}
if err := json.NewDecoder(body).Decode(&got); err != nil {
t.Fatalf("decode JSON: %v", err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("JSON mismatch:\ngot %+v\nwant %+v", got, want)
}
}
Avoid Test Anti-Patterns
var globalDB *sql.DB
func TestSomething(t *testing.T) {
db := setupTestDB(t)
...
}
if user.internalCache != nil { ... }
if user.Name != "Alice" { ... }
time.Sleep(100 * time.Millisecond)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("timed out")
}
CI/CD Integration
GitHub Actions
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
cache: true
- name: Run tests
run: go test ./... -race -coverprofile=coverage.out
- name: Check coverage
run: |
go tool cover -func=coverage.out | grep total
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')
echo "Coverage: $COVERAGE%"
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: coverage.out
Quick Reference
| Feature | Command/Usage |
|---|
| Run tests | go test ./... |
| Race detector | go test -race ./... |
| Specific test | go test -run TestName |
| Verbose | go test -v ./... |
| Benchmark | go test -bench=. |
| Benchmark + mem | go test -bench=. -benchmem |
| Fuzz | go test -fuzz FuzzName -fuzztime=30s |
| Coverage | go test -cover ./... |
| Coverage profile | go test -coverprofile=c.out && go tool cover -html=c.out |
| Parallel tests | t.Parallel() inside test |
| Capture variable | tt := tt before t.Run (Go <1.22) |
| Skip long tests | testing.Short() check + -short flag |