Unit Testing in Go

Unit testing is the process of testing individual pieces of code, like functions or methods, to make sure they work as expected. In Go, unit tests are written using the built-in testing package, which makes it straightforward to create and run tests.

Basic Unit Test

Let’s start with a simple function and write a test for it. Suppose we have a function that adds two numbers:

math.go
package math

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

To test this function, create a file ending with _test.go in the same package:

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 the test with go test in your terminal. If everything is correct, you’ll see something like PASS.

Table Driven Tests

For testing multiple cases of the same function, use table driven tests. This approach makes your tests more organized and easier to extend.

math_test.go
package math

import "testing"

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", 5, -3, 2},
    }
    
    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)
            }
        })
    }
}

This method lets you test various scenarios without repeating code.

Testing Private Functions

Sometimes you need to test functions that aren’t exported (start with lowercase). You can do this by putting the test in the same package. For example:

utils.go
package utils

func isEven(n int) bool {
    return n%2 == 0
}

func ProcessNumbers(nums []int) []int {
    var result []int
    for _, num := range nums {
        if isEven(num) {
            result = append(result, num*2)
        }
    }
    return result
}
utils_test.go
package utils

import "testing"

func TestIsEven(t *testing.T) {
    tests := []struct {
        input    int
        expected bool
    }{
        {2, true},
        {3, false},
        {0, true},
        {-2, true},
    }
    
    for _, tt := range tests {
        result := isEven(tt.input)
        if result != tt.expected {
            t.Errorf("isEven(%d) = %v; want %v", tt.input, result, tt.expected)
        }
    }
}

func TestProcessNumbers(t *testing.T) {
    input := []int{1, 2, 3, 4, 5, 6}
    expected := []int{4, 8, 12}
    
    result := ProcessNumbers(input)
    
    if len(result) != len(expected) {
        t.Fatalf("ProcessNumbers() length = %d; want %d", len(result), len(expected))
    }
    
    for i, v := range result {
        if v != expected[i] {
            t.Errorf("ProcessNumbers()[%d] = %d; want %d", i, v, expected[i])
        }
    }
}

Test Helpers

You can create helper functions to reduce code duplication in your tests. Helpers should be named with a prefix like assert or require.

math_test.go
package math

import "testing"

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

func TestMultiply(t *testing.T) {
    assertEqual(t, Multiply(2, 3), 6)
    assertEqual(t, Multiply(0, 5), 0)
    assertEqual(t, Multiply(-2, 3), -6)
}

The t.Helper() call makes the test failure point to the calling line instead of the helper function.

Testing with Setup and Teardown

For tests that need setup or cleanup, use TestMain or setup functions:

database_test.go
package database

import (
    "database/sql"
    "testing"
)

var testDB *sql.DB

func TestMain(m *testing.M) {
    // Setup
    var err error
    testDB, err = sql.Open("sqlite3", ":memory:")
    if err != nil {
        panic(err)
    }
    
    // Create tables
    _, err = testDB.Exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
    if err != nil {
        panic(err)
    }
    
    // Run tests
    code := m.Run()
    
    // Teardown
    testDB.Close()
    
    // Exit
    os.Exit(code)
}

func TestInsertUser(t *testing.T) {
    // Test logic using testDB
}

Mocking Dependencies

When testing functions that depend on external services, use interfaces to make mocking easier:

user.go
package user

type Database interface {
    SaveUser(name string) error
}

type UserService struct {
    db Database
}

func (s *UserService) CreateUser(name string) error {
    return s.db.SaveUser(name)
}
user_test.go
package user

import "testing"

type mockDatabase struct {
    savedUsers []string
}

func (m *mockDatabase) SaveUser(name string) error {
    m.savedUsers = append(m.savedUsers, name)
    return nil
}

func TestCreateUser(t *testing.T) {
    mockDB := &mockDatabase{}
    service := &UserService{db: mockDB}
    
    err := service.CreateUser("John")
    if err != nil {
        t.Errorf("CreateUser failed: %v", err)
    }
    
    if len(mockDB.savedUsers) != 1 || mockDB.savedUsers[0] != "John" {
        t.Error("User was not saved correctly")
    }
}

Running Tests

  • go test: Run all tests in current package
  • go test -v: Verbose output
  • go test -run TestName: Run specific test
  • go test -cover: Show coverage
  • go test -coverprofile=coverage.out: Generate coverage profile

Best Practices

  1. Name your tests clearly: Use descriptive names that explain what they’re testing
  2. Test one thing at a time: Each test should focus on a single behavior
  3. Use table driven tests for multiple similar cases
  4. Test edge cases: Don’t just test happy paths
  5. Keep tests fast: Slow tests discourage running them
  6. Use test helpers to reduce duplication
  7. Test error conditions: Make sure your code handles errors properly

For more information on testing in Go, check out the official testing documentation.

If you’re new to Go basics, you might want to read about Go functions first.

Last updated on