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
Testand take*testing.T - Test files are in the same package as the code they test
- Use
t.Errorort.Errorfto report failures (test continues) - Use
t.Fatalort.Fatalfto 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.Runcreates 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 continuesrequire— 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 allocationsns/op— nanoseconds per operationB/op— bytes allocated per operationallocs/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:
- Create a
httptest.NewRecorder()to capture the response - Create a
http.NewRequestwith the method, path, and body - Call
router.ServeHTTP(w, req) - Check
w.Code(status) andw.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:
Related Articles
- Go Tutorial #16: Building REST APIs with Gin — Build APIs to test
- Go Tutorial #18: Middleware and JWT Authentication — Add authentication to your API
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.