Testing
Go has a built-in testing framework. No external library needed. Write a _test.go file, run go test, done.
Basic Tests
// math.go
package math
func Add(a, b int) int {
return a + b
}
// math_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d, want %d", got, want)
}
}
go test ./... # run all tests
go test -v ./... # verbose output
go test -run TestAdd # run specific test
Table-Driven Tests
The standard Go testing pattern. Define inputs and expected outputs in a slice, loop over them with subtests:
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive", 2, 3, 5},
{"negative", -1, -2, -3},
{"zero", 0, 0, 0},
{"mixed", -1, 5, 4},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.expected {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.expected)
}
})
}
}
Run a single subtest:
go test -run TestAdd/negative
Tip: Name your test cases with short descriptive strings. They show up in failure output and make debugging easier.
Test Helpers
Extract common setup into helper functions. Use t.Helper() so failures report the caller’s line number:
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestCreateUser(t *testing.T) {
db := setupTestDB(t)
// test using db...
}
t.Cleanup registers a function to run after the test finishes (even if it fails). Use it instead of defer for test resources.
Test Fixtures
Read test data from a testdata/ directory (Go tooling ignores this directory during builds):
func TestParseConfig(t *testing.T) {
data, err := os.ReadFile("testdata/valid_config.json")
if err != nil {
t.Fatal(err)
}
cfg, err := ParseConfig(data)
if err != nil {
t.Fatal(err)
}
if cfg.Port != 8080 {
t.Errorf("port = %d, want 8080", cfg.Port)
}
}
For golden file testing (compare output against saved expected output):
func TestRender(t *testing.T) {
got := Render(input)
golden := filepath.Join("testdata", t.Name()+".golden")
if *update {
os.WriteFile(golden, []byte(got), 0644)
return
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatal(err)
}
if got != string(want) {
t.Errorf("output mismatch:\ngot:\n%s\nwant:\n%s", got, want)
}
}
Benchmarks
Benchmark functions start with Benchmark and take *testing.B:
func BenchmarkAdd(b *testing.B) {
for b.Loop() {
Add(2, 3)
}
}
func BenchmarkConcatStrings(b *testing.B) {
strs := []string{"hello", "world", "foo", "bar"}
for b.Loop() {
result := ""
for _, s := range strs {
result += s
}
}
}
func BenchmarkJoinStrings(b *testing.B) {
strs := []string{"hello", "world", "foo", "bar"}
for b.Loop() {
strings.Join(strs, "")
}
}
go test -bench=. -benchmem
# BenchmarkConcatStrings-8 5000000 312 ns/op 64 B/op 3 allocs/op
# BenchmarkJoinStrings-8 20000000 89 ns/op 32 B/op 1 allocs/op
Tip: Since Go 1.24, use
b.Loop()instead offor i := 0; i < b.N; i++. The compiler cannot optimize away work insideb.Loop(), so benchmarks are more accurate without needing//go:noinlinehacks.
Fuzzing (Go 1.18+)
Fuzz testing automatically generates inputs to find edge cases:
func FuzzParseURL(f *testing.F) {
// Seed corpus - provide example inputs
f.Add("https://example.com")
f.Add("http://localhost:8080/path?q=1")
f.Add("")
f.Fuzz(func(t *testing.T, input string) {
u, err := ParseURL(input)
if err != nil {
return // invalid input is fine
}
// Round-trip: parsed URL should produce the same string
if u.String() != input {
t.Errorf("round-trip failed: %q -> %q", input, u.String())
}
})
}
go test -fuzz=FuzzParseURL -fuzztime=30s
Failing inputs are saved to testdata/fuzz/ and replayed as regular tests on subsequent runs.
Test Coverage
go test -cover ./...
# ok myapp/internal/server 0.003s coverage: 82.3% of statements
# Generate HTML coverage report
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# See which functions lack coverage
go tool cover -func=coverage.out
Integration Testing with Build Tags
Separate slow/integration tests from unit tests:
//go:build integration
package store
import "testing"
func TestDatabaseIntegration(t *testing.T) {
db := connectToRealDB()
defer db.Close()
// ...
}
go test ./... # skips integration tests
go test -tags=integration ./... # includes integration tests
Or use short mode:
func TestSlow(t *testing.T) {
if testing.Short() {
t.Skip("skipping in short mode")
}
// long-running test...
}
go test -short ./... # skips slow tests
Mocking with Interfaces
No mocking framework needed. Define an interface, implement a test double:
// Production code defines the interface where it is used
type UserStore interface {
GetUser(ctx context.Context, id string) (*User, error)
SaveUser(ctx context.Context, u *User) error
}
type Service struct {
store UserStore
}
// Test double
type mockStore struct {
users map[string]*User
err error
}
func (m *mockStore) GetUser(_ context.Context, id string) (*User, error) {
if m.err != nil {
return nil, m.err
}
u, ok := m.users[id]
if !ok {
return nil, ErrNotFound
}
return u, nil
}
func (m *mockStore) SaveUser(_ context.Context, u *User) error {
if m.err != nil {
return m.err
}
m.users[u.ID] = u
return nil
}
func TestServiceGetUser(t *testing.T) {
store := &mockStore{
users: map[string]*User{
"1": {ID: "1", Name: "Alice"},
},
}
svc := &Service{store: store}
user, err := svc.GetUser(context.Background(), "1")
if err != nil {
t.Fatal(err)
}
if user.Name != "Alice" {
t.Errorf("name = %q, want Alice", user.Name)
}
}
func TestServiceGetUser_NotFound(t *testing.T) {
store := &mockStore{users: map[string]*User{}}
svc := &Service{store: store}
_, err := svc.GetUser(context.Background(), "999")
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
Gotcha: If you find yourself mocking everything, your design might be too coupled. Mocks should replace external dependencies (databases, APIs), not internal logic.
HTTP Testing
Test HTTP handlers without starting a real server:
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
healthHandler(w, req)
if w.Code != 200 {
t.Errorf("status = %d, want 200", w.Code)
}
if w.Body.String() != "ok" {
t.Errorf("body = %q, want ok", w.Body.String())
}
}
For full integration tests with middleware:
func TestAPI(t *testing.T) {
srv := httptest.NewServer(setupRouter())
defer srv.Close()
resp, err := http.Get(srv.URL + "/health")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
}
Next: Error Handling | API Server