Skip to main content
โšก Calmops

Unit Testing in Go

Unit Testing in Go

Testing is a first-class citizen in Go. The language includes a built-in testing package and encourages test-driven development. This guide covers everything you need to know about writing effective unit tests in Go.

Getting Started with Testing

Basic Test Structure

package main

import (
	"testing"
)

// Function to test
func Add(a, b int) int {
	return a + b
}

// Test function
func TestAdd(t *testing.T) {
	result := Add(2, 3)
	expected := 5

	if result != expected {
		t.Errorf("Add(2, 3) = %d; want %d", result, expected)
	}
}

func TestAddNegative(t *testing.T) {
	result := Add(-2, -3)
	expected := -5

	if result != expected {
		t.Errorf("Add(-2, -3) = %d; want %d", result, expected)
	}
}

Running Tests

# Run all tests in current package
go test

# Run with verbose output
go test -v

# Run specific test
go test -run TestAdd

# Run with coverage
go test -cover

# Generate coverage report
go test -coverprofile=coverage.out
go tool cover -html=coverage.out

Test Patterns

Table-Driven Tests

package main

import (
	"testing"
)

func TestAddTable(t *testing.T) {
	tests := []struct {
		name     string
		a, b     int
		expected int
	}{
		{"positive", 2, 3, 5},
		{"negative", -2, -3, -5},
		{"mixed", 5, -3, 2},
		{"zero", 0, 0, 0},
		{"with zero", 5, 0, 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)
			}
		})
	}
}

Subtests

package main

import (
	"testing"
)

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

	t.Run("subtraction", func(t *testing.T) {
		result := 5 - 3
		if result != 2 {
			t.Errorf("Subtraction failed: got %d, want 2", result)
		}
	})

	t.Run("multiplication", func(t *testing.T) {
		result := 3 * 4
		if result != 12 {
			t.Errorf("Multiplication failed: got %d, want 12", result)
		}
	})
}

Test Helpers and Utilities

Helper Functions

package main

import (
	"testing"
)

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

func TestWithHelper(t *testing.T) {
	result := Add(2, 3)
	assertEqual(t, result, 5)
}

Setup and Teardown

package main

import (
	"testing"
)

func TestWithSetup(t *testing.T) {
	// Setup
	data := []int{1, 2, 3, 4, 5}

	// Test
	if len(data) != 5 {
		t.Errorf("Expected 5 elements, got %d", len(data))
	}

	// Teardown (if needed)
	data = nil
}

func TestWithCleanup(t *testing.T) {
	// Setup
	resource := "test resource"

	// Register cleanup
	t.Cleanup(func() {
		// Cleanup code
		resource = ""
	})

	// Test
	if resource != "test resource" {
		t.Errorf("Resource not initialized")
	}
}

Testing Different Scenarios

Testing Errors

package main

import (
	"errors"
	"testing"
)

func Divide(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("division by zero")
	}
	return a / b, nil
}

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

	if err.Error() != "division by zero" {
		t.Errorf("Wrong error message: %v", err)
	}
}

func TestDivideSuccess(t *testing.T) {
	result, err := Divide(10, 2)
	if err != nil {
		t.Errorf("Unexpected error: %v", err)
	}

	if result != 5 {
		t.Errorf("Expected 5, got %d", result)
	}
}

Testing Interfaces

package main

import (
	"testing"
)

type Reader interface {
	Read() string
}

type MockReader struct {
	data string
}

func (m *MockReader) Read() string {
	return m.data
}

func ProcessReader(r Reader) string {
	return "Processed: " + r.Read()
}

func TestInterface(t *testing.T) {
	mock := &MockReader{data: "test"}
	result := ProcessReader(mock)

	expected := "Processed: test"
	if result != expected {
		t.Errorf("Expected %q, got %q", expected, result)
	}
}

Testing Concurrency

package main

import (
	"sync"
	"testing"
	"time"
)

func TestConcurrency(t *testing.T) {
	var wg sync.WaitGroup
	results := make([]int, 0)
	var mu sync.Mutex

	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			mu.Lock()
			results = append(results, n)
			mu.Unlock()
		}(i)
	}

	wg.Wait()

	if len(results) != 10 {
		t.Errorf("Expected 10 results, got %d", len(results))
	}
}

func TestTimeout(t *testing.T) {
	done := make(chan bool)

	go func() {
		time.Sleep(100 * time.Millisecond)
		done <- true
	}()

	select {
	case <-done:
		// Success
	case <-time.After(1 * time.Second):
		t.Error("Test timed out")
	}
}

Practical Testing Examples

Testing String Functions

package main

import (
	"strings"
	"testing"
)

func TestStringFunctions(t *testing.T) {
	tests := []struct {
		name     string
		input    string
		contains string
		expected bool
	}{
		{"contains", "hello world", "world", true},
		{"not contains", "hello world", "xyz", false},
		{"empty", "", "test", false},
		{"case sensitive", "Hello", "hello", false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := strings.Contains(tt.input, tt.contains)
			if result != tt.expected {
				t.Errorf("Contains(%q, %q) = %v; want %v",
					tt.input, tt.contains, result, tt.expected)
			}
		})
	}
}

Testing Slices

package main

import (
	"testing"
)

func Contains(slice []int, value int) bool {
	for _, v := range slice {
		if v == value {
			return true
		}
	}
	return false
}

func TestSliceOperations(t *testing.T) {
	slice := []int{1, 2, 3, 4, 5}

	tests := []struct {
		name     string
		value    int
		expected bool
	}{
		{"found", 3, true},
		{"not found", 10, false},
		{"first", 1, true},
		{"last", 5, true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := Contains(slice, tt.value)
			if result != tt.expected {
				t.Errorf("Contains(%v, %d) = %v; want %v",
					slice, tt.value, result, tt.expected)
			}
		})
	}
}

Testing Maps

package main

import (
	"testing"
)

func TestMapOperations(t *testing.T) {
	m := map[string]int{
		"apple":  5,
		"banana": 3,
		"cherry": 8,
	}

	tests := []struct {
		name     string
		key      string
		expected int
		exists   bool
	}{
		{"exists", "apple", 5, true},
		{"not exists", "date", 0, false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			value, ok := m[tt.key]
			if ok != tt.exists {
				t.Errorf("Key %q exists = %v; want %v", tt.key, ok, tt.exists)
			}
			if ok && value != tt.expected {
				t.Errorf("m[%q] = %d; want %d", tt.key, value, tt.expected)
			}
		})
	}
}

Best Practices

โœ… Good Practices

// Use table-driven tests
func TestFunction(t *testing.T) {
	tests := []struct {
		name     string
		input    interface{}
		expected interface{}
	}{
		// Test cases
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Test logic
		})
	}
}

// Use t.Helper() for helper functions
func assertEqual(t *testing.T, got, want interface{}) {
	t.Helper()
	if got != want {
		t.Errorf("got %v, want %v", got, want)
	}
}

// Test error cases
func TestError(t *testing.T) {
	_, err := functionThatErrors()
	if err == nil {
		t.Error("Expected error")
	}
}

// Use subtests for organization
t.Run("subtest", func(t *testing.T) {
	// Subtest logic
})

โŒ Anti-Patterns

// Don't use generic test names
func TestFunction(t *testing.T) {
	// Hard to identify which case failed
}

// Don't ignore errors
result, _ := functionThatErrors()
// Should check error

// Don't test implementation details
// Test behavior, not internal state

// Don't create interdependent tests
// Each test should be independent

Resources

Summary

Go’s testing package provides powerful tools for writing effective tests:

  • Use table-driven tests for multiple scenarios
  • Organize tests with subtests
  • Use helper functions for common assertions
  • Test both success and error cases
  • Keep tests independent and focused
  • Aim for good coverage but prioritize meaningful tests

With these practices, you can build reliable, well-tested Go applications.

Comments