Unit Testing Fundamentals
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