Unit Testing #

Go has outstanding built-in testing support. No external frameworks like JUnit or pytest needed — the testing package in the standard library is enough for everything: unit tests, benchmarks, example tests, and fuzz tests. The tooling is integrated directly with the go command, and the conventions are so consistent across the Go ecosystem that every Go developer can immediately understand tests written by someone else.

Basic Conventions #

Test file rules:
  - The file name must end with _test.go
  - _test.go files are NOT compiled into the production binary
  - Can be in the same package (white-box) or package_test (black-box)

Test function rules:
  - Start with Test (not test or TEST)
  - Accept one parameter: t *testing.T
  - Return nothing

Examples:
  func TestFunctionName(t *testing.T) { ... }  ✓
  func testFunctionName(t *testing.T) { ... }  ✗ not run by go test
  func TestFunctionName() { ... }              ✗ compile error

White-box vs Black-box Testing #

// calculator.go
package calculator

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

// White-box: same package, can access unexported code
// calculator_test.go
package calculator

func TestAdd(t *testing.T) {
    _ = add(1, 2)  // can access the unexported function
}

// Black-box: different package, only exported access
// calculator_test.go
package calculator_test

import "myapp/calculator"

func TestAdd(t *testing.T) {
    result := calculator.Add(1, 2)  // only exported
    _ = result
}

testing.T — Main Methods #

func TestExample(t *testing.T) {
    // Log — print extra info (only shown if the test fails or with -v)
    t.Log("Starting the test...")
    t.Logf("Value: %d", 42)

    // Error — mark the test failed but CONTINUE execution
    t.Error("something is wrong")
    t.Errorf("value %d doesn't match expected %d", got, want)

    // Fatal — mark the test failed and STOP this test
    t.Fatal("critical error, cannot continue")
    t.Fatalf("cannot open file: %v", err)

    // Skip — skip this test (e.g. in certain environments)
    if runtime.GOOS == "windows" {
        t.Skip("this test is not supported on Windows")
    }

    // Fail / FailNow — like Error/Fatal but without a message
    t.Fail()     // mark failed, continue
    t.FailNow()  // mark failed, stop
}

Table-Driven Tests — The Idiomatic Go Pattern #

This is the most important pattern in Go testing. Instead of one test function per scenario, all scenarios are grouped in a single table (a slice of structs). The execution flow structure of Table-Driven Tests can be illustrated in the following diagram:

flowchart TD
    Start["Start TestDivide(t *testing.T)"] --> DefineTable["Define Slice of Struct 'tests'\n(A collection of test scenarios)"]
    DefineTable --> Loop["Iterate tt := range tests"]
    Loop --> RunSub["t.Run(tt.name, func)"]
    RunSub --> RunCode["Run the Function Under Test:\ngot, err := Divide(tt.a, tt.b)"]
    RunCode --> Verify{"Result matches expectation?"}
    Verify -->|"Yes"| Success["Subtest Succeeds"]
    Verify -->|"No"| Fail["t.Errorf / t.Fatalf (Subtest Fails)"]
    Success & Fail --> NextIter{"More scenarios?"}
    NextIter -->|"Yes"| Loop
    NextIter -->|"No"| EndTest["Finish TestDivide"]
func TestDivide(t *testing.T) {
    tests := []struct {
        name    string
        a, b    float64
        want    float64
        wantErr bool
    }{
        {name: "normal division",  a: 10, b: 2,  want: 5,  wantErr: false},
        {name: "decimal division", a: 7,  b: 2,  want: 3.5, wantErr: false},
        {name: "zero divisor",     a: 10, b: 0,  want: 0,  wantErr: true},
        {name: "negative division", a: -6, b: 3,  want: -2, wantErr: false},
        {name: "both zero",        a: 0,  b: 0,  want: 0,  wantErr: true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := Divide(tt.a, tt.b)

            // Check the error
            if (err != nil) != tt.wantErr {
                t.Errorf("Divide() error = %v, wantErr = %v", err, tt.wantErr)
                return
            }

            // Check the result (only if there's no error)
            if !tt.wantErr && got != tt.want {
                t.Errorf("Divide() = %v, want %v", got, tt.want)
            }
        })
    }
}

Running specific tests with -run:

go test ./...                          # all tests
go test -run TestDivide                # all TestDivide subtests
go test -run TestDivide/zero_divisor   # a specific subtest (spaces → _)
go test -v ./...                       # verbose — show all output

t.Parallel() — Concurrent Testing #

Tests that don’t depend on each other can run in parallel to speed up the test suite:

func TestSlow(t *testing.T) {
    tests := []struct {
        name  string
        input int
    }{
        {"case A", 1},
        {"case B", 2},
        {"case C", 3},
    }

    for _, tt := range tests {
        tt := tt  // IMPORTANT: capture the loop variable before Go 1.22
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()  // this subtest runs concurrently with other subtests
            time.Sleep(100 * time.Millisecond)  // simulate a slow operation
            // ... test logic
        })
    }
}
Capture the loop variable before t.Parallel(). Before Go 1.22, the loop variable was shared across iterations — without tt := tt, all goroutines would use the same tt value (the last one). Since Go 1.22, this is fixed at the language level, but this defensive habit is still good to keep.

t.Helper() — Proper Helper Functions #

When creating helper functions for tests, call t.Helper() so the reported line on failure is the caller’s line, not a line inside the helper:

// Without t.Helper() — the error points to the line inside assertEqual
func assertEqual(t *testing.T, got, want int) {
    if got != want {
        t.Errorf("got %d, want %d", got, want)  // ← this line gets reported
    }
}

// With t.Helper() — the error points to the caller of assertEqual
func assertEqual(t *testing.T, got, want int) {
    t.Helper()  // ← add this!
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

// Usage
func TestSomething(t *testing.T) {
    result := compute(5)
    assertEqual(t, result, 10)  // ← this line gets reported on failure ✓
}

TestMain — Global Setup and Teardown #

TestMain runs before and after all tests in a package — useful for database connections, test servers, etc.:

func TestMain(m *testing.M) {
    // SETUP — run before all tests
    db, err := setupTestDatabase()
    if err != nil {
        log.Fatal("Failed to set up test database:", err)
    }
    testDB = db  // store in a package-level variable

    // Run all tests
    code := m.Run()

    // TEARDOWN — run after all tests
    testDB.Close()
    cleanupTestData()

    os.Exit(code)  // REQUIRED: use the exit code from m.Run()
}

Benchmarks #

Benchmarks measure function performance. Benchmark functions start with Benchmark and accept *testing.B:

func BenchmarkAdd(b *testing.B) {
    // b.N is set automatically by the testing framework
    // to get stable results
    for i := 0; i < b.N; i++ {
        Add(100, 200)
    }
}

func BenchmarkSort(b *testing.B) {
    // Setup outside the loop — not measured
    data := generateLargeSlice(10000)

    b.ResetTimer()  // reset the timer after setup
    for i := 0; i < b.N; i++ {
        // Copy the data because sort modifies the slice
        input := make([]int, len(data))
        copy(input, data)
        sort.Ints(input)
    }
}

func BenchmarkWithAllocs(b *testing.B) {
    b.ReportAllocs()  // show memory allocation statistics
    for i := 0; i < b.N; i++ {
        _ = fmt.Sprintf("hello %d", i)  // allocates a new string each time
    }
}

// Benchmarks with various input sizes
func BenchmarkProcess(b *testing.B) {
    sizes := []int{100, 1000, 10000}
    for _, size := range sizes {
        b.Run(fmt.Sprintf("size-%d", size), func(b *testing.B) {
            data := generateData(size)
            b.ResetTimer()
            for i := 0; i < b.N; i++ {
                process(data)
            }
        })
    }
}

Running benchmarks:

go test -bench .                    # all benchmarks
go test -bench BenchmarkSort        # a specific benchmark
go test -bench . -benchmem          # show memory allocations
go test -bench . -benchtime 5s      # run for at least 5 seconds
go test -bench . -count 3           # repeat 3 times for accuracy

Output:

BenchmarkSort-8    10000    115234 ns/op    81920 B/op    1 allocs/op
                   ^       ^               ^              ^
                   N       ns per op       bytes/op       allocs/op

httptest — Testing HTTP Handlers #

The net/http/httptest package lets you test HTTP handlers without a real server:

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestHealthHandler(t *testing.T) {
    // Create a request and a response recorder
    req := httptest.NewRequest(http.MethodGet, "/health", nil)
    rr := httptest.NewRecorder()

    // Call the handler directly
    healthHandler(rr, req)

    // Check the status code
    if rr.Code != http.StatusOK {
        t.Errorf("status code = %d, want %d", rr.Code, http.StatusOK)
    }

    // Check the response body
    var resp map[string]string
    json.NewDecoder(rr.Body).Decode(&resp)
    if resp["status"] != "ok" {
        t.Errorf("status = %q, want %q", resp["status"], "ok")
    }
}

func TestCreateUserHandler(t *testing.T) {
    tests := []struct {
        name       string
        body       string
        wantStatus int
    }{
        {
            name:       "valid user",
            body:       `{"name":"Budi","email":"[email protected]"}`,
            wantStatus: http.StatusCreated,
        },
        {
            name:       "missing name",
            body:       `{"email":"[email protected]"}`,
            wantStatus: http.StatusBadRequest,
        },
        {
            name:       "invalid JSON",
            body:       `{invalid}`,
            wantStatus: http.StatusBadRequest,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest(
                http.MethodPost, "/users",
                strings.NewReader(tt.body),
            )
            req.Header.Set("Content-Type", "application/json")
            rr := httptest.NewRecorder()

            createUserHandler(rr, req)

            if rr.Code != tt.wantStatus {
                t.Errorf("status = %d, want %d\nbody: %s",
                    rr.Code, tt.wantStatus, rr.Body.String())
            }
        })
    }
}

// Testing with httptest.NewServer for full integration
func TestAPIIntegration(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 != http.StatusOK {
        t.Errorf("status = %d, want 200", resp.StatusCode)
    }
}

Code Coverage #

# Run tests with coverage
go test -cover ./...

# Output:
# ok  myapp/calculator  coverage: 87.5% of statements

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

# Show per-function coverage
go tool cover -func=coverage.out

# Show as interactive HTML in the browser
go tool cover -html=coverage.out

# Set a minimum coverage (useful in CI)
go test -cover ./... | grep -v "100.0%" | grep "coverage:"

Build Tags for Integration Tests #

Separate fast unit tests from slow integration tests (that need infrastructure):

// integration_test.go
//go:build integration

package myapp_test

import (
    "testing"
    "database/sql"
)

// This test only runs with: go test -tags=integration ./...
func TestDatabaseIntegration(t *testing.T) {
    db, err := sql.Open("postgres", os.Getenv("TEST_DATABASE_URL"))
    if err != nil {
        t.Skip("DATABASE_URL not available")
    }
    defer db.Close()
    // ... test with a real database
}
# Unit tests only (fast)
go test ./...

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

Complete Example Program — Payment Service Test Suite #

// payment.go
package payment

import (
    "errors"
    "fmt"
    "time"
)

type Currency string

const (
    IDR Currency = "IDR"
    USD Currency = "USD"
)

var (
    ErrInsufficientFunds = errors.New("insufficient balance")
    ErrInvalidAmount     = errors.New("invalid amount")
    ErrAccountNotFound   = errors.New("account not found")
    ErrSameAccount       = errors.New("cannot transfer to the same account")
)

type Account struct {
    ID       string
    Name     string
    Balance  float64
    Currency Currency
}

type Transaction struct {
    ID        string
    FromID    string
    ToID      string
    Amount    float64
    Currency  Currency
    CreatedAt time.Time
}

type Repository interface {
    FindAccount(id string) (*Account, error)
    UpdateBalance(id string, balance float64) error
    SaveTransaction(tx Transaction) error
}

type Service struct {
    repo Repository
}

func NewService(repo Repository) *Service {
    return &Service{repo: repo}
}

func (s *Service) Transfer(fromID, toID string, amount float64) (*Transaction, error) {
    if amount <= 0 {
        return nil, ErrInvalidAmount
    }
    if fromID == toID {
        return nil, ErrSameAccount
    }

    from, err := s.repo.FindAccount(fromID)
    if err != nil {
        return nil, fmt.Errorf("sender account: %w", err)
    }

    to, err := s.repo.FindAccount(toID)
    if err != nil {
        return nil, fmt.Errorf("receiver account: %w", err)
    }

    if from.Balance < amount {
        return nil, ErrInsufficientFunds
    }

    if err := s.repo.UpdateBalance(fromID, from.Balance-amount); err != nil {
        return nil, fmt.Errorf("update sender: %w", err)
    }

    if err := s.repo.UpdateBalance(toID, to.Balance+amount); err != nil {
        // Rollback
        _ = s.repo.UpdateBalance(fromID, from.Balance)
        return nil, fmt.Errorf("update receiver: %w", err)
    }

    tx := Transaction{
        ID:        fmt.Sprintf("TRX-%d", time.Now().UnixNano()),
        FromID:    fromID,
        ToID:      toID,
        Amount:    amount,
        Currency:  from.Currency,
        CreatedAt: time.Now(),
    }

    if err := s.repo.SaveTransaction(tx); err != nil {
        return nil, fmt.Errorf("save transaction: %w", err)
    }

    return &tx, nil
}

// payment_test.go
package payment

import (
    "errors"
    "testing"
)

// ── In-Memory Repository for tests ──────────────────────────

type mockRepo struct {
    accounts     map[string]*Account
    transactions []Transaction
    failUpdate   string // account ID that intentionally fails on update
}

func newMockRepo(accounts ...*Account) *mockRepo {
    r := &mockRepo{accounts: make(map[string]*Account)}
    for _, a := range accounts {
        r.accounts[a.ID] = a
    }
    return r
}

func (r *mockRepo) FindAccount(id string) (*Account, error) {
    a, ok := r.accounts[id]
    if !ok {
        return nil, ErrAccountNotFound
    }
    // Return a copy so it can't be modified directly
    copy := *a
    return &copy, nil
}

func (r *mockRepo) UpdateBalance(id string, balance float64) error {
    if r.failUpdate == id {
        return errors.New("database error")
    }
    a, ok := r.accounts[id]
    if !ok {
        return ErrAccountNotFound
    }
    a.Balance = balance
    return nil
}

func (r *mockRepo) SaveTransaction(tx Transaction) error {
    r.transactions = append(r.transactions, tx)
    return nil
}

// ── Helpers ──────────────────────────────────────────────────

func requireNoError(t *testing.T, err error) {
    t.Helper()
    if err != nil {
        t.Fatalf("did not expect an error: %v", err)
    }
}

func requireError(t *testing.T, err error, target error) {
    t.Helper()
    if err == nil {
        t.Fatalf("expected error %v, but there was no error", target)
    }
    if !errors.Is(err, target) {
        t.Fatalf("error = %v, want %v", err, target)
    }
}

func assertBalance(t *testing.T, repo *mockRepo, accountID string, want float64) {
    t.Helper()
    a, err := repo.FindAccount(accountID)
    if err != nil {
        t.Fatalf("failed to check balance: %v", err)
    }
    if a.Balance != want {
        t.Errorf("balance of %s = %.2f, want %.2f", accountID, a.Balance, want)
    }
}

// ── Test Cases ────────────────────────────────────────────────

func TestTransfer(t *testing.T) {
    tests := []struct {
        name        string
        fromBalance float64
        toBalance   float64
        amount      float64
        wantErr     error
        wantFromBal float64
        wantToBal   float64
    }{
        {
            name:        "normal transfer",
            fromBalance: 1_000_000,
            toBalance:   500_000,
            amount:      300_000,
            wantErr:     nil,
            wantFromBal: 700_000,
            wantToBal:   800_000,
        },
        {
            name:        "exact balance",
            fromBalance: 500_000,
            toBalance:   0,
            amount:      500_000,
            wantErr:     nil,
            wantFromBal: 0,
            wantToBal:   500_000,
        },
        {
            name:        "insufficient balance",
            fromBalance: 100_000,
            toBalance:   500_000,
            amount:      200_000,
            wantErr:     ErrInsufficientFunds,
            wantFromBal: 100_000, // unchanged
            wantToBal:   500_000,
        },
        {
            name:        "zero amount",
            fromBalance: 1_000_000,
            toBalance:   0,
            amount:      0,
            wantErr:     ErrInvalidAmount,
            wantFromBal: 1_000_000,
            wantToBal:   0,
        },
        {
            name:        "negative amount",
            fromBalance: 1_000_000,
            toBalance:   0,
            amount:      -50_000,
            wantErr:     ErrInvalidAmount,
            wantFromBal: 1_000_000,
            wantToBal:   0,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            repo := newMockRepo(
                &Account{ID: "A001", Name: "Budi", Balance: tt.fromBalance, Currency: IDR},
                &Account{ID: "A002", Name: "Sari", Balance: tt.toBalance, Currency: IDR},
            )
            svc := NewService(repo)

            _, err := svc.Transfer("A001", "A002", tt.amount)

            if tt.wantErr != nil {
                requireError(t, err, tt.wantErr)
            } else {
                requireNoError(t, err)
            }

            assertBalance(t, repo, "A001", tt.wantFromBal)
            assertBalance(t, repo, "A002", tt.wantToBal)
        })
    }
}

func TestTransferValidation(t *testing.T) {
    repo := newMockRepo(
        &Account{ID: "A001", Balance: 1_000_000, Currency: IDR},
    )
    svc := NewService(repo)

    t.Run("sender account missing", func(t *testing.T) {
        _, err := svc.Transfer("MISSING", "A001", 100_000)
        requireError(t, err, ErrAccountNotFound)
    })

    t.Run("receiver account missing", func(t *testing.T) {
        _, err := svc.Transfer("A001", "MISSING", 100_000)
        requireError(t, err, ErrAccountNotFound)
    })

    t.Run("transfer to own account", func(t *testing.T) {
        _, err := svc.Transfer("A001", "A001", 100_000)
        requireError(t, err, ErrSameAccount)
    })
}

func TestTransferRollback(t *testing.T) {
    // Simulate: the receiver update fails — the sender's balance must be restored
    repo := newMockRepo(
        &Account{ID: "A001", Balance: 1_000_000, Currency: IDR},
        &Account{ID: "A002", Balance: 500_000, Currency: IDR},
    )
    repo.failUpdate = "A002"  // force a failure when updating A002
    svc := NewService(repo)

    _, err := svc.Transfer("A001", "A002", 300_000)
    if err == nil {
        t.Fatal("there should have been an error")
    }

    // Make sure the rollback succeeded — A001's balance didn't decrease
    assertBalance(t, repo, "A001", 1_000_000)
    assertBalance(t, repo, "A002", 500_000)
}

func BenchmarkTransfer(b *testing.B) {
    repo := newMockRepo(
        &Account{ID: "A001", Balance: 1e12, Currency: IDR},
        &Account{ID: "A002", Balance: 1e12, Currency: IDR},
    )
    svc := NewService(repo)

    b.ResetTimer()
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        // Alternating transfers so the balance doesn't run out
        if i%2 == 0 {
            svc.Transfer("A001", "A002", 1000)
        } else {
            svc.Transfer("A002", "A001", 1000)
        }
    }
}

Summary #

  • Conventions: _test.go files, functions starting with Test, *testing.T parameter — no external framework needed.
  • Table-driven tests are the idiomatic Go pattern — group all scenarios in a slice of structs, iterate with t.Run.
  • t.Helper() must be called at the start of every helper function so errors point to the caller, not inside the helper.
  • t.Parallel() runs subtests concurrently — capture the loop variable (tt := tt) before Go 1.22.
  • TestMain for global setup/teardown (databases, test servers) — don’t forget os.Exit(m.Run()).
  • Benchmarks: use b.ResetTimer() after setup, b.ReportAllocs() for memory statistics.
  • httptest.NewRecorder() tests HTTP handlers without a real server; httptest.NewServer() for full integration.
  • -coverprofile generates coverage reports; go tool cover -html for interactive visualization.
  • Build tags (//go:build integration) separate unit tests from integration tests.
  • Tests in the same package for white-box testing; package_test for black-box testing.

← Previous: Web Server   Next: Mocking →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact