Skip to content
Home
Testing and Mocking in Go

Testing and Mocking in Go

Go Go 8 min read 1531 words Beginner ExcellentWiki Editorial Team

Testing is a first-class concern in Go. The standard library includes a powerful testing framework, a built-in benchmark runner, and a fuzzing engine — no third-party tools required. This guide covers everything from basic unit tests to advanced mocking patterns.

The testing Package

Go’s testing package provides the foundation. Test files follow the naming convention *_test.go and are excluded from production builds by the compiler.

Basic Unit 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) {
    result := Add(2, 3)
    expected := 5
    if result != expected {
        t.Errorf("Add(2, 3) = %d; want %d", result, expected)
    }
---

Run tests with:

go test                                    # current package
go test ./...                              # all packages
go test -v ./...                           # verbose output
go test -run TestAdd                       # specific test
go test -count=1 ./...                     # disable caching

Test functions must have the signature func TestXxx(t *testing.T). The *testing.T parameter provides methods for logging, error reporting, and test control.

Table-Driven Tests

Table-driven tests are the idiomatic Go pattern for testing multiple cases:

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

    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)
            }
        })
    }
---

Table-driven tests are preferred over duplicated test functions because they make edge cases visible, reduce boilerplate, and make it easy to add new cases.

Setup and Teardown

func TestDatabase(t *testing.T) {
    db := setupDatabase(t)
    t.Cleanup(func() {
        db.Close()
    })
    // run tests...
---

t.Cleanup registers a function to run when the test completes or fails. This is cleaner than defer in tests because it works correctly with t.Parallel().

Test Helpers

Use t.Helper() to mark helper functions, which excludes them from failure stack traces:

func assertEqual(t *testing.T, got, want any) {
    t.Helper()
    if got != want {
        t.Fatalf("got %v, want %v", got, want)
    }
---

func TestAdd(t *testing.T) {
    assertEqual(t, Add(2, 3), 5)
    assertEqual(t, Add(10, -5), 5)
---

Without t.Helper(), a failure would point to the assertEqual line. With it, the failure points to the actual test function line — much more useful during debugging.

Mocking with Interfaces

Go’s implicit interface satisfaction makes mocking straightforward. The standard pattern is:

  1. Define an interface for the dependency
  2. Write a mock implementation
  3. Inject the mock in tests
// mailer/mailer.go
package mailer

type Mailer interface {
    Send(to, subject, body string) error
---

type SMTPMailer struct {
    host string
    port int
---

func (m *SMTPMailer) Send(to, subject, body string) error {
    // real SMTP implementation
    return nil
---

// handler/handler.go
package handler

type Handler struct {
    mailer mailer.Mailer
---

func NewHandler(m mailer.Mailer) *Handler {
    return &Handler{mailer: m}
---

func (h *Handler) NotifyUser(email string) error {
    return h.mailer.Send(email, "Welcome!", "Thanks for signing up")
---

Mock Implementation

// handler/handler_test.go
package handler

type mockMailer struct {
    sentTo []string
---

func (m *mockMailer) Send(to, subject, body string) error {
    m.sentTo = append(m.sentTo, to)
    return nil
---

func TestNotifyUser(t *testing.T) {
    mailer := &mockMailer{}
    h := NewHandler(mailer)

    h.NotifyUser("user@example.com")

    if len(mailer.sentTo) != 1 {
        t.Fatalf("expected 1 email, got %d", len(mailer.sentTo))
    }
    if mailer.sentTo[0] != "user@example.com" {
        t.Errorf("expected user@example.com, got %s", mailer.sentTo[0])
    }
---

Testing HTTP Handlers

func TestHandlerGet(t *testing.T) {
    mockStore := &mockStore{data: map[int]string{1: "item1"}}
    h := NewHandler(mockStore)

    req := httptest.NewRequest("GET", "/items/1", nil)
    rec := httptest.NewRecorder()

    h.GetItem(rec, req)

    if rec.Code != http.StatusOK {
        t.Errorf("expected 200, got %d", rec.Code)
    }

    var resp map[string]string
    json.NewDecoder(rec.Body).Decode(&resp)
    if resp["name"] != "item1" {
        t.Errorf("expected item1, got %s", resp["name"])
    }
---

The httptest package provides NewRequest, NewRecorder, and NewServer — everything needed to test HTTP handlers without network calls.

Testing with Testify

While the standard library is sufficient, testify adds convenient assertions and mocking:

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

type MockStore struct {
    mock.Mock
---

func (m *MockStore) Get(id int) (string, error) {
    args := m.Called(id)
    return args.String(0), args.Error(1)
---

func TestGetItem(t *testing.T) {
    mockStore := new(MockStore)
    mockStore.On("Get", 1).Return("item1", nil)

    result, err := mockStore.Get(1)
    assert.NoError(t, err)
    assert.Equal(t, "item1", result)
    mockStore.AssertExpectations(t)
---

Testify’s assert package produces better error messages than raw if checks and the mock package provides strict expectation verification.

Benchmarks

Benchmark functions use *testing.B and measure performance:

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(i, i+1)
    }
---
go test -bench=. -benchmem
go test -bench=BenchmarkAdd -count=5 ./...

The b.N is adjusted by the framework to get a stable measurement. Always loop through b.N — never use a fixed iteration count.

Useful Benchmark Patterns

// Benchmark different input sizes
func BenchmarkAddSmall(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(1, 2)
    }
---

func BenchmarkAddLarge(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(1000000, 2000000)
    }
---

// Reset timer after setup
func BenchmarkExpensive(b *testing.B) {
    data := generateLargeDataset()
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        process(data)
    }
---

Fuzzing

Go 1.18+ includes built-in fuzzing. Fuzz tests automatically generate inputs to find edge cases:

func FuzzReverse(f *testing.F) {
    testcases := []string{"hello", "world", "12345"}
    for _, tc := range testcases {
        f.Add(tc) // seed corpus
    }

    f.Fuzz(func(t *testing.T, s string) {
        reversed := Reverse(s)
        doubleReversed := Reverse(reversed)
        if s != doubleReversed {
            t.Errorf("Reverse(Reverse(%q)) != %q", s, s)
        }
    })
---
go test -fuzz=FuzzReverse

The fuzzer runs until it finds a crash or is interrupted. Found inputs are saved to testdata/fuzz/FuzzReverse/ and automatically replayed in future runs.

Integration Tests

For tests that need external services, use build tags to separate them:

// integration_test.go
//go:build integration

package store

import (
    "testing"
)

func TestPostgresStore(t *testing.T) {
    db := setupTestDB(t)
    defer db.Close()

    // integration tests...
---
go test -tags=integration ./...

Use TEST_DATABASE_URL or similar environment variables to point to test instances. The testcontainers-go library can spin up disposable Docker containers for true integration testing.

Test Main

Use TestMain for package-level setup/teardown:

func TestMain(m *testing.M) {
    setup()
    code := m.Run()
    teardown()
    os.Exit(code)
---

TestMain runs before any test in the package. Use it sparingly — it is often better to keep setup in individual test functions with t.Cleanup.

Test Coverage

go test -cover ./...
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out  # browser view
go tool cover -func=coverage.out  # per-function breakdown

Coverage is a useful signal but not a goal. Focus on covering critical paths, error handling, and edge cases rather than chasing 100% coverage. The Pareto principle applies: 80% of bugs come from 20% of the code.

Golden File Testing

Golden file tests compare output against approved files (useful for complex output like HTML, JSON, or CLI output):

func TestHTMLOutput(t *testing.T) {
    result := generateHTML()
    golden := filepath.Join("testdata", t.Name()+".golden")

    if *update {
        os.WriteFile(golden, []byte(result), 0644)
    }

    expected, err := os.ReadFile(golden)
    if err != nil {
        t.Fatalf("failed to read golden file: %v", err)
    }

    if result != string(expected) {
        t.Errorf("output doesn't match golden file")
    }
---

Golden files should be committed to version control and reviewed alongside code changes. When output intentionally changes, run go test -update to regenerate them, then review the diff in git.

Testing with Test Containers

For integration tests that need real databases or services:

import (
    "testing"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/wait"
)

func TestWithPostgres(t *testing.T) {
    ctx := context.Background()
    req := testcontainers.ContainerRequest{
        Image:        "postgres:16",
        ExposedPorts: []string{"5432/tcp"},
        Env: map[string]string{
            "POSTGRES_USER":     "test",
            "POSTGRES_PASSWORD": "test",
            "POSTGRES_DB":       "testdb",
        },
        WaitingFor: wait.ForLog("database system is ready to accept connections"),
    }

    pg, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: req,
        Started:          true,
    })
    if err != nil {
        t.Fatal(err)
    }
    defer pg.Terminate(ctx)

    // Run tests against the container
    db := connectToPG(pg)
    // ... test logic
---

FAQ

Q: How do I test code that uses time? A: Inject a clock interface or use time.Now in a way that can be overridden in tests. The github.com/benbjohnson/clock package provides a testable clock.

Q: What is the difference between t.Error and t.Fatal? A: t.Error reports a failure but continues the test. t.Fatal stops the test immediately. Use t.Error when you want to report multiple failures; use t.Fatal when continuing makes no sense.

Q: How do I test concurrent code? A: Use sync.WaitGroup and timeouts. The race detector (go test -race) catches data races. For deterministic concurrency tests, use channels with select and timeouts.

Q: Can I run tests in parallel? A: Yes, call t.Parallel() at the start of a test function. Tests sharing resources need synchronization. Parallel execution is controlled by -parallel N.

Q: How do I test a main package? A: Create main_test.go in the same directory. Use func TestMain(m *testing.M) for the entry point, but test individual functions rather than the whole program.

Q: What is the best mocking library for Go? A: The standard library’s net/http/httptest and manual interface implementations suffice for most projects. Testify’s mock package is popular for complex scenarios. gomock generates mocks from interfaces.

Q: How do I test file I/O? A: Use os.CreateTemp to create temp files, or abstract the filesystem behind an io.Reader/io.Writer interface. The testing/fstest package provides a test filesystem.

For a comprehensive overview, read our article on Getting Started With Go.

For a comprehensive overview, read our article on Go Concurrency Guide.

Section: Go 1531 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top