Write tests as a slice of structs. Name the slice tests and each element tt. Run each with t.Run.
func TestDivide(t *testing.T) {
t.Parallel()
tests := []struct {
name string
dividend float64
divisor float64
want float64
wantErr bool
}{
{name: "positive", dividend: 10, divisor: 2, want: 5},
{name: "negative divisor", dividend: 10, divisor: -2, want: -5},
{name: "fractional result", dividend: 7, divisor: 2, want: 3.5},
{name: "zero divisor", dividend: 10, divisor: 0, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // run subtests in parallel when safe
got, err := Divide(tt.dividend, tt.divisor)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("Divide(%v, %v) = %v, want %v",
tt.dividend, tt.divisor, got, tt.want)
}
})
}
}
Use t.Fatal when further execution is meaningless. Use t.Error to accumulate multiple failures. Capture loop variables before t.Parallel() in Go versions before 1.22 (Go 1.22+ fixes loop variable capture automatically).
Mark helper functions with t.Helper() so failures report the caller's line, not the helper's.
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func assertEqual[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}
Register cleanup functions that run even if the test panics or calls t.Fatal.
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("opening db: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("closing db: %v", err)
}
})
return db
}
Use t.TempDir() instead of os.MkdirTemp. It is automatically removed after the test.
func TestWriteFile(t *testing.T) {
dir := t.TempDir() // cleaned up automatically
path := filepath.Join(dir, "output.txt")
err := WriteFile(path, []byte("hello"))
if err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != "hello" {
t.Errorf("got %q, want %q", got, "hello")
}
}
Place static input files in testdata/. The Go tool ignores this directory for builds. Reference files relative to the package root using filepath.Join("testdata", "input.json").
func TestParseConfig(t *testing.T) {
data, err := os.ReadFile(filepath.Join("testdata", "config.json"))
if err != nil {
t.Fatal(err)
}
cfg, err := ParseConfig(data)
if err != nil {
t.Fatalf("ParseConfig: %v", err)
}
if cfg.Port != 8080 {
t.Errorf("got port %d, want 8080", cfg.Port)
}
}
Define narrow interfaces at the point of use, not in the package that implements them.
// Define the interface (in the consumer's package)
type UserStore interface {
GetUser(ctx context.Context, id int64) (*User, error)
SaveUser(ctx context.Context, u *User) error
}
// Hand-rolled mock (no external dependencies)
type mockUserStore struct {
getUser func(ctx context.Context, id int64) (*User, error)
saveUser func(ctx context.Context, u *User) error
calls []string
}
func (m *mockUserStore) GetUser(ctx context.Context, id int64) (*User, error) {
m.calls = append(m.calls, "GetUser")
if m.getUser != nil {
return m.getUser(ctx, id)
}
return nil, nil
}
func (m *mockUserStore) SaveUser(ctx context.Context, u *User) error {
m.calls = append(m.calls, "SaveUser")
if m.saveUser != nil {
return m.saveUser(ctx, u)
}
return nil
}
// Test using the mock
func TestUserService_Promote(t *testing.T) {
store := &mockUserStore{
getUser: func(_ context.Context, id int64) (*User, error) {
return &User{ID: id, Role: "member"}, nil
},
saveUser: func(_ context.Context, u *User) error {
if u.Role != "admin" {
return fmt.Errorf("expected role admin, got %s", u.Role)
}
return nil
},
}
svc := NewUserService(store)
err := svc.Promote(context.Background(), 42)
if err != nil {
t.Fatalf("Promote: %v", err)
}
if len(store.calls) != 2 {
t.Errorf("expected 2 calls, got %d: %v", len(store.calls), store.calls)
}
}
Install: go get github.com/stretchr/testify.
assert logs failure and continues. require stops the test immediately (calls t.FailNow).
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUserCreation(t *testing.T) {
user, err := NewUser("alice@example.com")
require.NoError(t, err) // stop if error
require.NotNil(t, user) // stop if nil
assert.Equal(t, "alice@example.com", user.Email)
assert.Empty(t, user.PasswordHash) // multiple checks continue on failure
assert.WithinDuration(t, time.Now(), user.CreatedAt, time.Second)
}
Group related tests with shared setup/teardown.
import "github.com/stretchr/testify/suite"
type UserSuite struct {
suite.Suite
db *sql.DB
svc *UserService
}
func (s *UserSuite) SetupSuite() {
db, err := sql.Open("sqlite3", ":memory:")
s.Require().NoError(err)
s.db = db
s.svc = NewUserService(db)
}
func (s *UserSuite) TearDownSuite() {
s.db.Close()
}
func (s *UserSuite) SetupTest() {
_, err := s.db.Exec("DELETE FROM users")
s.Require().NoError(err)
}
func (s *UserSuite) TestCreate() {
u, err := s.svc.Create(context.Background(), "bob@example.com")
s.Require().NoError(err)
s.Equal("bob@example.com", u.Email)
}
func TestUserSuite(t *testing.T) {
suite.Run(t, new(UserSuite))
}
Use mock.Mock for dynamic expectations with call counting.
import "github.com/stretchr/testify/mock"
type MockStore struct {
mock.Mock
}
func (m *MockStore) GetUser(ctx context.Context, id int64) (*User, error) {
args := m.Called(ctx, id)
return args.Get(0).(*User), args.Error(1)
}
func TestWithMock(t *testing.T) {
store := new(MockStore)
store.On("GetUser", mock.Anything, int64(1)).
Return(&User{ID: 1, Name: "Alice"}, nil)
svc := NewUserService(store)
user, err := svc.GetUser(context.Background(), 1)
require.NoError(t, err)
assert.Equal(t, "Alice", user.Name)
store.AssertExpectations(t)
}
import "net/http/httptest"
func TestGetUserHandler(t *testing.T) {
store := &mockUserStore{
getUser: func(_ context.Context, id int64) (*User, error) {
return &User{ID: id, Name: "Alice"}, nil
},
}
h := NewHandler(store)
req := httptest.NewRequest(http.MethodGet, "/users/1", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, want 200", resp.StatusCode)
}
var got User
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decoding response: %v", err)
}
if got.Name != "Alice" {
t.Errorf("name %q, want Alice", got.Name)
}
}
func TestAPIClient(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/users/42" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"id":42,"name":"Bob"}`)
}))
defer srv.Close()
client := NewAPIClient(srv.URL)
user, err := client.GetUser(context.Background(), 42)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if user.Name != "Bob" {
t.Errorf("name %q, want Bob", user.Name)
}
}
// TLS variant
func TestAPIClientTLS(t *testing.T) {
srv := httptest.NewTLSServer(myHandler)
defer srv.Close()
client := srv.Client() // pre-configured to trust the test certificate
resp, err := client.Get(srv.URL + "/health")
// ...
}
Functions named BenchmarkXxx receive *testing.B. Run with go test -bench=. -benchmem.
func BenchmarkEncode(b *testing.B) {
user := &User{ID: 1, Name: "Alice", Email: "alice@example.com"}
b.ReportAllocs() // show allocations per op
b.ResetTimer() // exclude setup time
for b.Loop() { // Go 1.24+; use i := 0; i < b.N; i++ for older versions
if _, err := json.Marshal(user); err != nil {
b.Fatal(err)
}
}
}
// Sub-benchmarks compare implementations
func BenchmarkEncoding(b *testing.B) {
user := &User{ID: 1, Name: "Alice", Email: "alice@example.com"}
b.Run("json/stdlib", func(b *testing.B) {
for b.Loop() {
json.Marshal(user)
}
})
b.Run("json/sonic", func(b *testing.B) {
for b.Loop() {
sonic.Marshal(user)
}
})
}
// Parallel benchmark
func BenchmarkEncodeParallel(b *testing.B) {
user := &User{ID: 1, Name: "Alice"}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
json.Marshal(user)
}
})
}
Run and compare: go test -bench=BenchmarkEncoding -benchmem -count=5 | tee new.txt && benchstat old.txt new.txt.
Fuzz tests find inputs that crash your code. Run normally as unit tests; enable fuzzing with -fuzz.
func FuzzParseURL(f *testing.F) {
// Seed the corpus with known-good inputs
f.Add("https://example.com/path?q=1")
f.Add("http://localhost:8080")
f.Add("")
f.Add("not-a-url")
f.Fuzz(func(t *testing.T, raw string) {
// Must not panic
u, err := ParseURL(raw)
if err != nil {
return // errors are acceptable
}
// Round-trip property: re-parsing the output must succeed
reparsed, err := ParseURL(u.String())
if err != nil {
t.Errorf("round-trip failed for %q: %v", u.String(), err)
}
if reparsed.String() != u.String() {
t.Errorf("round-trip mismatch: %q != %q", reparsed.String(), u.String())
}
})
}
Run fuzzing: go test -fuzz=FuzzParseURL -fuzztime=30s. Failing inputs are saved to testdata/fuzz/FuzzParseURL/. Reproduce: go test -run=FuzzParseURL/testdata/fuzz/FuzzParseURL/<id>.
Guard integration tests with a build tag so go test ./... skips them by default.
//go:build integration
package store_test
import (
"testing"
// ...
)
func TestPostgresUserStore(t *testing.T) {
dsn := os.Getenv("TEST_DSN")
if dsn == "" {
t.Skip("TEST_DSN not set")
}
// ...
}
Run: go test -tags integration ./...
Spin up real databases in Docker for integration tests.
//go:build integration
func TestWithPostgres(t *testing.T) {
ctx := context.Background()
container, err := postgres.RunContainer(ctx,
testcontainers.WithImage("postgres:16"),
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2)),
)
if err != nil {
t.Fatalf("starting postgres: %v", err)
}
t.Cleanup(func() { container.Terminate(ctx) })
dsn, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatal(err)
}
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
// Run migrations, then test
runMigrations(t, db)
store := NewPostgresStore(db)
// ... test store methods
}
Golden files store expected output. Re-generate them with -update.
var update = flag.Bool("update", false, "update golden files")
func TestRenderMarkdown(t *testing.T) {
input, err := os.ReadFile(filepath.Join("testdata", "input.md"))
if err != nil {
t.Fatal(err)
}
got := RenderMarkdown(input)
golden := filepath.Join("testdata", "golden", "output.html")
if *update {
err := os.WriteFile(golden, got, 0644)
if err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Errorf("output mismatch (-want +got):\n%s",
cmp.Diff(string(want), string(got)))
}
}
Run go test -run=TestRenderMarkdown -update to regenerate, then commit the golden files.
func TestMain(m *testing.M) {
// Setup: runs once before any test
db, err := setupTestDatabase()
if err != nil {
fmt.Fprintf(os.Stderr, "setup: %v\n", err)
os.Exit(1)
}
globalDB = db
// Run tests
code := m.Run()
// Teardown: runs once after all tests
db.Close()
os.Exit(code)
}
Prefer t.Cleanup over defer in test helpers; it composes across multiple helpers cleanly.
func prepareUser(t *testing.T, db *sql.DB, email string) *User {
t.Helper()
u, err := db.CreateUser(context.Background(), email)
if err != nil {
t.Fatalf("creating user: %v", err)
}
t.Cleanup(func() {
if err := db.DeleteUser(context.Background(), u.ID); err != nil {
t.Logf("cleanup: deleting user %d: %v", u.ID, err)
}
})
return u
}
Enable with go test -race ./.... The race detector adds ~5-10x overhead; use it in CI.
// RACE: multiple goroutines write to results without synchronization
func badCollect(items []Item) []Result {
results := make([]Result, 0, len(items))
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(it Item) {
defer wg.Done()
results = append(results, process(it)) // DATA RACE
}(item)
}
wg.Wait()
return results
}
// FIXED: preallocate by index
func goodCollect(items []Item) []Result {
results := make([]Result, len(items))
var wg sync.WaitGroup
for i, item := range items {
wg.Add(1)
go func(i int, it Item) {
defer wg.Done()
results[i] = process(it) // safe: each goroutine owns its index
}(i, item)
}
wg.Wait()
return results
}
// RACE in Go < 1.22
for _, url := range urls {
go func() {
fetch(url) // captures loop variable by reference
}()
}
// FIXED
for _, url := range urls {
url := url // shadow with local copy
go func() {
fetch(url)
}()
}
# Generate coverage profile
go test -coverprofile=coverage.out ./...
# View summary by package
go tool cover -func=coverage.out
# Open interactive HTML report
go tool cover -html=coverage.out
# Enforce a minimum threshold in CI
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out | awk '/^total:/ {pct=$3+0; if (pct < 80) {print "coverage "$pct"% below 80%"; exit 1}}'
Target 80% coverage for business logic. Avoid chasing 100%: generated code, main functions, and deliberate error paths that only trigger under hardware failure are not worth testing directly.