Unit Testing Fundamentals

6 minute read

Unit Testing Overview

Unit tests verify individual functions work correctly in isolation. They’re fast, focused, and form the foundation of your test suite.

Go Testing Basics

Simple Test

package provider

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

Using testify/assert

package provider

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

func TestAdd(t *testing.T) {
    result := add(2, 3)
    assert.Equal(t, 5, result, "add(2, 3) should equal 5")
}

Table-Driven Tests

Basic Pattern

func TestValidateEngineName(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        wantErr bool
    }{
        {
            name:    "valid redis",
            input:   "redis",
            wantErr: false,
        },
        {
            name:    "valid memcached",
            input:   "memcached",
            wantErr: false,
        },
        {
            name:    "invalid empty",
            input:   "",
            wantErr: true,
        },
        {
            name:    "invalid unknown engine",
            input:   "postgres",
            wantErr: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := validateEngineName(tt.input)
            
            if tt.wantErr {
                assert.Error(t, err, "validateEngineName(%q) should return error", tt.input)
            } else {
                assert.NoError(t, err, "validateEngineName(%q) should not return error", tt.input)
            }
        })
    }
}

Complex Test Cases

func TestParseCacheTags(t *testing.T) {
    tests := []struct {
        name     string
        input    map[string]string
        expected map[string]types.String
        wantErr  bool
    }{
        {
            name: "valid tags",
            input: map[string]string{
                "env":  "production",
                "team": "platform",
            },
            expected: map[string]types.String{
                "env":  types.StringValue("production"),
                "team": types.StringValue("platform"),
            },
            wantErr: false,
        },
        {
            name:     "empty tags",
            input:    map[string]string{},
            expected: map[string]types.String{},
            wantErr:  false,
        },
        {
            name: "nil input",
            input:    nil,
            expected: map[string]types.String{},
            wantErr:  false,
        },
        {
            name: "invalid tag key",
            input: map[string]string{
                "":     "value",
                "team": "platform",
            },
            expected: nil,
            wantErr:  true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := parseCacheTags(tt.input)
            
            if tt.wantErr {
                assert.Error(t, err)
                return
            }
            
            assert.NoError(t, err)
            assert.Equal(t, tt.expected, result)
        })
    }
}

Testing Schema Validation

Validator Tests

func TestPortValidator(t *testing.T) {
    validator := portValidator{}
    
    tests := []struct {
        name    string
        port    int64
        wantErr bool
        errMsg  string
    }{
        {
            name:    "valid port 1024",
            port:    1024,
            wantErr: false,
        },
        {
            name:    "valid port 65535",
            port:    65535,
            wantErr: false,
        },
        {
            name:    "invalid port too low",
            port:    1023,
            wantErr: true,
            errMsg:  "must be between 1024 and 65535",
        },
        {
            name:    "invalid port too high",
            port:    65536,
            wantErr: true,
            errMsg:  "must be between 1024 and 65535",
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := validator.ValidateInt64Request{
                Value: types.Int64Value(tt.port),
            }
            resp := &validator.ValidateInt64Response{}
            
            validator.ValidateInt64(context.Background(), req, resp)
            
            if tt.wantErr {
                assert.True(t, resp.Diagnostics.HasError())
                assert.Contains(t, resp.Diagnostics.Errors()[0].Summary(), tt.errMsg)
            } else {
                assert.False(t, resp.Diagnostics.HasError())
            }
        })
    }
}

Plan Modifier Tests

func TestDefaultEngineVersionModifier(t *testing.T) {
    tests := []struct {
        name     string
        planVal  types.String
        stateVal types.String
        expected types.String
    }{
        {
            name:     "null plan value gets default",
            planVal:  types.StringNull(),
            stateVal: types.StringNull(),
            expected: types.StringValue("7.0"),
        },
        {
            name:     "unknown plan value gets default",
            planVal:  types.StringUnknown(),
            stateVal: types.StringNull(),
            expected: types.StringValue("7.0"),
        },
        {
            name:     "known plan value unchanged",
            planVal:  types.StringValue("6.2"),
            stateVal: types.StringNull(),
            expected: types.StringValue("6.2"),
        },
        {
            name:     "state value preserved",
            planVal:  types.StringNull(),
            stateVal: types.StringValue("6.0"),
            expected: types.StringValue("6.0"),
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            modifier := defaultEngineVersionModifier{}
            
            req := planmodifier.StringRequest{
                PlanValue:  tt.planVal,
                StateValue: tt.stateVal,
            }
            resp := &planmodifier.StringResponse{}
            
            modifier.PlanModifyString(context.Background(), req, resp)
            
            assert.Equal(t, tt.expected, resp.PlanValue)
        })
    }
}

Testing Helper Functions

Conversion Functions

func TestFlattenTags(t *testing.T) {
    tests := []struct {
        name     string
        input    map[string]string
        expected types.Map
    }{
        {
            name: "valid tags",
            input: map[string]string{
                "env":  "production",
                "team": "platform",
            },
            expected: types.MapValueMust(
                types.StringType,
                map[string]attr.Value{
                    "env":  types.StringValue("production"),
                    "team": types.StringValue("platform"),
                },
            ),
        },
        {
            name:     "empty tags",
            input:    map[string]string{},
            expected: types.MapNull(types.StringType),
        },
        {
            name:     "nil tags",
            input:    nil,
            expected: types.MapNull(types.StringType),
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := flattenTags(tt.input)
            assert.Equal(t, tt.expected, result)
        })
    }
}

func TestExpandTags(t *testing.T) {
    tests := []struct {
        name     string
        input    types.Map
        expected map[string]string
    }{
        {
            name: "valid map",
            input: types.MapValueMust(
                types.StringType,
                map[string]attr.Value{
                    "env":  types.StringValue("production"),
                    "team": types.StringValue("platform"),
                },
            ),
            expected: map[string]string{
                "env":  "production",
                "team": "platform",
            },
        },
        {
            name:     "null map",
            input:    types.MapNull(types.StringType),
            expected: map[string]string{},
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := expandTags(tt.input)
            assert.Equal(t, tt.expected, result)
        })
    }
}

Testing Error Cases

Error Handling

func TestParseEndpoint(t *testing.T) {
    tests := []struct {
        name      string
        input     string
        expectErr bool
        errMsg    string
        expected  *Endpoint
    }{
        {
            name:  "valid endpoint",
            input: "cache.example.com:6379",
            expected: &Endpoint{
                Host: "cache.example.com",
                Port: 6379,
            },
            expectErr: false,
        },
        {
            name:      "missing port",
            input:     "cache.example.com",
            expectErr: true,
            errMsg:    "missing port",
        },
        {
            name:      "invalid port",
            input:     "cache.example.com:abc",
            expectErr: true,
            errMsg:    "invalid port",
        },
        {
            name:      "empty input",
            input:     "",
            expectErr: true,
            errMsg:    "endpoint cannot be empty",
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := parseEndpoint(tt.input)
            
            if tt.expectErr {
                assert.Error(t, err)
                assert.Contains(t, err.Error(), tt.errMsg)
                assert.Nil(t, result)
            } else {
                assert.NoError(t, err)
                assert.Equal(t, tt.expected, result)
            }
        })
    }
}

Test Organization

File Structure

internal/provider/
├── cache_resource.go
├── cache_resource_test.go        # Unit tests
├── cache_resource_acc_test.go    # Acceptance tests
├── cache_data_source.go
├── cache_data_source_test.go
├── helpers.go
├── helpers_test.go
├── provider.go
└── provider_test.go

Test Naming Convention

// Pattern: Test{FunctionName}_{Scenario}

func TestValidateEngineName_Valid(t *testing.T) { ... }
func TestValidateEngineName_Invalid(t *testing.T) { ... }
func TestParseTags_EmptyMap(t *testing.T) { ... }
func TestParseTags_NilInput(t *testing.T) { ... }

Setup and Teardown

Test Setup

func TestCacheOperations(t *testing.T) {
    // Setup
    mockClient := &MockClient{}
    resource := &CacheResource{client: mockClient}
    
    t.Run("Create", func(t *testing.T) {
        // Test create...
    })
    
    t.Run("Read", func(t *testing.T) {
        // Test read...
    })
    
    // Teardown happens automatically
}

Cleanup with defer

func TestWithCleanup(t *testing.T) {
    tempFile, err := os.CreateTemp("", "test-*")
    assert.NoError(t, err)
    defer os.Remove(tempFile.Name())  // Cleanup
    
    // Test using tempFile...
}

Test Helpers

func newTestCacheModel(name string) CacheModel {
    return CacheModel{
        Name:          types.StringValue(name),
        Engine:        types.StringValue("redis"),
        EngineVersion: types.StringValue("7.0"),
        NodeType:      types.StringValue("cache.t3.micro"),
        NumNodes:      types.Int64Value(1),
    }
}

func TestCacheResource_Create(t *testing.T) {
    plan := newTestCacheModel("test-cache")
    // Use plan in test...
}

Testing Best Practices

1. Test One Thing

// ✅ Good: Tests one scenario
func TestValidateEngineName_Redis(t *testing.T) {
    err := validateEngineName("redis")
    assert.NoError(t, err)
}

func TestValidateEngineName_EmptyString(t *testing.T) {
    err := validateEngineName("")
    assert.Error(t, err)
}

// ❌ Bad: Tests multiple things
func TestValidateEngineName(t *testing.T) {
    assert.NoError(t, validateEngineName("redis"))
    assert.Error(t, validateEngineName(""))
    assert.Error(t, validateEngineName("postgres"))
}

2. Use Descriptive Names

// ✅ Good: Clear what's being tested
func TestValidatePort_BelowMinimum_ReturnsError(t *testing.T) { ... }
func TestValidatePort_AboveMaximum_ReturnsError(t *testing.T) { ... }
func TestValidatePort_ValidRange_Success(t *testing.T) { ... }

// ❌ Bad: Unclear names
func TestPort1(t *testing.T) { ... }
func TestPort2(t *testing.T) { ... }

3. Arrange-Act-Assert

func TestParseTags(t *testing.T) {
    // Arrange
    input := map[string]string{
        "env": "production",
    }
    
    // Act
    result, err := parseTags(input)
    
    // Assert
    assert.NoError(t, err)
    assert.Equal(t, "production", result["env"])
}

Running Tests

Run All Tests

go test ./...

Run Specific Test

go test -run TestValidateEngineName

Run with Verbose Output

go test -v ./...

Run with Coverage

go test -cover ./...
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

Run Specific Package

go test ./internal/provider/

Coverage Analysis

Check Coverage

$ go test -cover ./internal/provider/
ok      github.com/example/terraform-provider-cachecloud/internal/provider  0.5s  coverage: 85.3% of statements

Generate Coverage Report

# Generate coverage data
go test -coverprofile=coverage.out ./...

# View HTML report
go tool cover -html=coverage.out

# View function-level coverage
go tool cover -func=coverage.out

Coverage Goals

├── Unit Tests:        80-90% coverage
├── Integration Tests: 70-80% coverage
└── Acceptance Tests:  Critical paths only

Key Takeaways

  • ✅ Use table-driven tests for multiple scenarios
  • ✅ Test both happy path and error cases
  • ✅ Keep tests fast and isolated
  • ✅ Use descriptive test names
  • ✅ Follow Arrange-Act-Assert pattern
  • ✅ Test one thing per test
  • ✅ Use subtests for organization
  • ✅ Clean up resources with defer
  • ✅ Aim for 80%+ code coverage
  • ✅ Use testify/assert for cleaner assertions

What’s Next?

Learn how to use Terraform’s acceptance testing framework for end-to-end tests.

Next: Acceptance Testing Framework →


Previous: ← Testing Strategy

Last updated: July 3, 2026

Updated: