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 beforet.Parallel(). Before Go 1.22, the loop variable was shared across iterations — withouttt := tt, all goroutines would use the samettvalue (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 ©, 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.gofiles, functions starting withTest,*testing.Tparameter — 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.TestMainfor global setup/teardown (databases, test servers) — don’t forgetos.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.-coverprofilegenerates coverage reports;go tool cover -htmlfor interactive visualization.- Build tags (
//go:build integration) separate unit tests from integration tests.- Tests in the same package for white-box testing;
package_testfor black-box testing.