In the previous tutorial, you built REST APIs with Gin. Now it is time to test your code. Go has a powerful testing framework built into the standard library — no external dependencies needed.

Testing in Go is simple by design. Test files live next to the code they test, and you run them with one command: go test.

Your First Test

Create a file called math.go:

package math

func Add(a, b int) int {
    return a + b
}

func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, fmt.Errorf("cannot divide by zero")
    }
    return a / b, nil
}

Now create math_test.go in the same directory:

package math

import (
    "testing"
)

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d, want 5", result)
    }
}

func TestDivide(t *testing.T) {
    result, err := Divide(10, 2)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if result != 5 {
        t.Errorf("Divide(10, 2) = %d, want 5", result)
    }
}

func TestDivideByZero(t *testing.T) {
    _, err := Divide(10, 0)
    if err == nil {
        t.Error("expected error for divide by zero")
    }
}

Run the tests:

go test ./...

Output:

ok      example/math    0.002s

Key rules for Go tests:

  • Test files end with _test.go
  • Test functions start with Test and take *testing.T
  • Test files are in the same package as the code they test
  • Use t.Error or t.Errorf to report failures (test continues)
  • Use t.Fatal or t.Fatalf to report failures (test stops)

Table-Driven Tests

Table-driven tests are Go’s most important testing pattern. Instead of writing a separate function for each case, you define a table of inputs and expected outputs:

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -1, -2, -3},
        {"zero", 0, 0, 0},
        {"mixed signs", -1, 5, 4},
        {"large numbers", 1000000, 2000000, 3000000},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

Run a specific subtest:

go test -run TestAdd/positive_numbers

Table-driven tests are great because:

  • Easy to add new test cases — just add a row
  • Each case has a name — clear error messages
  • t.Run creates subtests — run them individually
  • One structure covers all edge cases

Testing with testify

The standard library works well, but testify makes assertions more readable:

go get github.com/stretchr/testify
package math

import (
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestAddWithTestify(t *testing.T) {
    // assert reports failure but continues the test
    assert.Equal(t, 5, Add(2, 3))
    assert.Equal(t, 0, Add(0, 0))
    assert.Equal(t, -3, Add(-1, -2))
}

func TestDivideWithTestify(t *testing.T) {
    // require stops the test on failure
    result, err := Divide(10, 2)
    require.NoError(t, err)
    assert.Equal(t, 5, result)
}

func TestDivideByZeroWithTestify(t *testing.T) {
    _, err := Divide(10, 0)
    assert.Error(t, err)
    assert.Contains(t, err.Error(), "divide by zero")
}

The difference between assert and require:

  • assert — reports failure, test continues
  • require — reports failure, test stops immediately

Use require for errors that make the rest of the test meaningless. Use assert for checking values.

Benchmark Tests

Go has built-in benchmarking. Benchmark functions start with Benchmark and take *testing.B:

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(100, 200)
    }
}

func BenchmarkDivide(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Divide(100, 3)
    }
}

Run benchmarks:

go test -bench=. -benchmem

Output:

BenchmarkAdd-8       1000000000     0.2890 ns/op    0 B/op    0 allocs/op
BenchmarkDivide-8    536870912      2.234 ns/op     0 B/op    0 allocs/op
  • b.N — Go automatically adjusts this number for accurate timing
  • -bench=. — run all benchmarks (regex pattern)
  • -benchmem — show memory allocations
  • ns/op — nanoseconds per operation
  • B/op — bytes allocated per operation
  • allocs/op — allocations per operation

Fuzzing

Fuzzing tests your code with random inputs to find edge cases you did not think of. Go 1.18+ has built-in fuzzing:

func FuzzDivide(f *testing.F) {
    // Seed corpus — initial test cases
    f.Add(10, 2)
    f.Add(100, 5)
    f.Add(-1, 3)
    f.Add(0, 1)

    f.Fuzz(func(t *testing.T, a, b int) {
        if b == 0 {
            // Skip divide by zero — we know it returns an error
            t.Skip("skip divide by zero")
        }

        result, err := Divide(a, b)
        if err != nil {
            t.Errorf("unexpected error for Divide(%d, %d): %v", a, b, err)
        }

        // Verify the result makes sense
        if result*b > a || result*b < a-b {
            t.Errorf("Divide(%d, %d) = %d, result out of range", a, b, result)
        }
    })
}

Run fuzzing:

# Run for 30 seconds
go test -fuzz=FuzzDivide -fuzztime=30s

The fuzzer generates random inputs and tries to find inputs that crash your code. If it finds one, it saves it as a test case in testdata/fuzz/ so it runs on every future go test.

Testing HTTP Handlers

Go’s net/http/httptest package lets you test HTTP handlers without starting a real server:

package main

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"

    "github.com/gin-gonic/gin"
    "github.com/stretchr/testify/assert"
)

func setupRouter() *gin.Engine {
    gin.SetMode(gin.TestMode)
    r := gin.Default()

    r.GET("/users", getUsers)
    r.GET("/users/:id", getUserByID)
    r.POST("/users", createUser)

    return r
}

func TestGetUsers(t *testing.T) {
    router := setupRouter()

    w := httptest.NewRecorder()
    req, _ := http.NewRequest("GET", "/users", nil)
    router.ServeHTTP(w, req)

    assert.Equal(t, http.StatusOK, w.Code)

    var response []User
    err := json.Unmarshal(w.Body.Bytes(), &response)
    assert.NoError(t, err)
    assert.Len(t, response, 2) // We start with 2 users
}

func TestGetUserByID(t *testing.T) {
    router := setupRouter()

    tests := []struct {
        name       string
        id         string
        wantStatus int
    }{
        {"existing user", "1", http.StatusOK},
        {"missing user", "999", http.StatusNotFound},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            w := httptest.NewRecorder()
            req, _ := http.NewRequest("GET", "/users/"+tt.id, nil)
            router.ServeHTTP(w, req)

            assert.Equal(t, tt.wantStatus, w.Code)
        })
    }
}

func TestCreateUser(t *testing.T) {
    router := setupRouter()

    body := `{"id":"3","name":"Jordan","email":"jordan@example.com"}`
    w := httptest.NewRecorder()
    req, _ := http.NewRequest("POST", "/users", strings.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    router.ServeHTTP(w, req)

    assert.Equal(t, http.StatusCreated, w.Code)

    var user User
    err := json.Unmarshal(w.Body.Bytes(), &user)
    assert.NoError(t, err)
    assert.Equal(t, "Jordan", user.Name)
}

The pattern is always the same:

  1. Create a httptest.NewRecorder() to capture the response
  2. Create a http.NewRequest with the method, path, and body
  3. Call router.ServeHTTP(w, req)
  4. Check w.Code (status) and w.Body (response)

Mocking with Interfaces

Go does not need a mocking framework. Use interfaces to replace real dependencies with test doubles:

// Define an interface for your dependency
type UserRepository interface {
    FindByID(id string) (*User, error)
    Save(user *User) error
}

// Production implementation
type PostgresUserRepo struct {
    db *sql.DB
}

func (r *PostgresUserRepo) FindByID(id string) (*User, error) {
    // Real database query
    return nil, nil
}

func (r *PostgresUserRepo) Save(user *User) error {
    // Real database insert
    return nil
}

// Test implementation — no database needed
type MockUserRepo struct {
    users map[string]*User
}

func NewMockUserRepo() *MockUserRepo {
    return &MockUserRepo{users: make(map[string]*User)}
}

func (r *MockUserRepo) FindByID(id string) (*User, error) {
    user, ok := r.users[id]
    if !ok {
        return nil, fmt.Errorf("user not found")
    }
    return user, nil
}

func (r *MockUserRepo) Save(user *User) error {
    r.users[user.ID] = user
    return nil
}

Now your handler uses the interface, not the concrete type:

type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) GetUser(id string) (*User, error) {
    return s.repo.FindByID(id)
}

In tests, pass the mock:

func TestGetUser(t *testing.T) {
    repo := NewMockUserRepo()
    repo.users["1"] = &User{ID: "1", Name: "Alex"}

    service := NewUserService(repo)

    user, err := service.GetUser("1")
    assert.NoError(t, err)
    assert.Equal(t, "Alex", user.Name)
}

func TestGetUserNotFound(t *testing.T) {
    repo := NewMockUserRepo()
    service := NewUserService(repo)

    _, err := service.GetUser("999")
    assert.Error(t, err)
}

This is the Go way — accept interfaces, return structs. No mocking framework needed.

Test Coverage

Check how much of your code is tested:

# Show coverage percentage
go test -cover ./...

# Generate coverage profile
go test -coverprofile=coverage.out ./...

# View coverage in browser
go tool cover -html=coverage.out

Output:

ok      example/math    0.003s    coverage: 87.5% of statements

The HTML report highlights which lines are covered (green) and which are not (red). Aim for high coverage on critical paths — 80% or more is a good target.

Test Helpers

Use t.Helper() to write reusable test helpers with clean error messages:

func assertStatusCode(t *testing.T, got, want int) {
    t.Helper() // Mark this as a helper — errors show the caller's line
    if got != want {
        t.Errorf("status code = %d, want %d", got, want)
    }
}

func TestAPI(t *testing.T) {
    w := httptest.NewRecorder()
    // ... make request ...
    assertStatusCode(t, w.Code, http.StatusOK) // Error points HERE, not inside the helper
}

Without t.Helper(), the error message would point to the line inside assertStatusCode. With it, the error points to the line that called the helper.

Running Tests

Common test commands:

# Run all tests
go test ./...

# Run tests in a specific package
go test ./math/

# Run a specific test
go test -run TestAdd ./...

# Run with verbose output
go test -v ./...

# Run with race detector
go test -race ./...

# Run benchmarks only
go test -bench=. -run=^$ ./...

# Run with timeout
go test -timeout 30s ./...

# Run with short flag (skip long tests)
go test -short ./...

Use -short for fast feedback during development:

func TestIntegration(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test in short mode")
    }
    // Long-running test here
}

Common Mistakes

1. Not using table-driven tests.

Writing a separate function for each test case creates a lot of duplicate code. Table-driven tests are cleaner and easier to maintain.

2. Testing private functions directly.

Test the public API of your package. If you need to test a private function, it is a sign that it should be public or that the public function needs more test cases.

3. Not using the race detector.

Always run go test -race during development. Race conditions are hard to find manually and easy to catch with the detector.

Source Code

You can find the complete source code for this tutorial on GitHub:

GO-17 Source Code on GitHub

What’s Next?

In the next tutorial, Go Tutorial #18: Middleware and JWT Authentication, you will learn:

  • What middleware is and how to write it in Gin
  • JWT authentication for your API
  • Password hashing with bcrypt
  • Protecting routes with auth middleware

This is part 17 of the Go Tutorial series. Follow along to learn Go from scratch.