Testing Lab: Complete Test Suite

7 minute read

Lab Overview

Build a complete testing suite for a Cache resource covering unit tests, mocks, and acceptance tests.

Lab Objectives

  • Create unit tests for validation logic
  • Build mock client for isolated testing
  • Write acceptance tests for CRUD operations
  • Test import and update scenarios
  • Organize test helpers and fixtures

Project Structure

terraform-provider-cachecloud/
├── internal/
│   ├── provider/
│   │   ├── cache_resource.go
│   │   ├── cache_resource_test.go       # Acceptance tests
│   │   ├── cache_validator.go
│   │   ├── cache_validator_test.go       # Unit tests
│   │   └── provider_test.go
│   ├── client/
│   │   ├── client.go
│   │   ├── client_test.go                # Unit tests with httptest
│   │   └── mock_client.go                # Mock implementation
│   └── acctest/
│       ├── acctest.go                    # Shared helpers
│       └── fixtures.go                   # Test fixtures

Step 1: Create Validation Unit Tests

Create Validator

// internal/provider/cache_validator.go
package provider

import (
    "context"
    "fmt"
    "regexp"
    
    "github.com/hashicorp/terraform-plugin-framework/schema/validator"
)

type cacheEngineValidator struct{}

func (v cacheEngineValidator) Description(ctx context.Context) string {
    return "value must be either 'redis' or 'memcached'"
}

func (v cacheEngineValidator) MarkdownDescription(ctx context.Context) string {
    return "value must be either `redis` or `memcached`"
}

func (v cacheEngineValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
    if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() {
        return
    }
    
    value := req.ConfigValue.ValueString()
    
    if value != "redis" && value != "memcached" {
        resp.Diagnostics.AddAttributeError(
            req.Path,
            "Invalid Cache Engine",
            fmt.Sprintf("Engine must be either 'redis' or 'memcached', got: %s", value),
        )
    }
}

func CacheEngine() validator.String {
    return cacheEngineValidator{}
}

Write Unit Tests

// internal/provider/cache_validator_test.go
package provider

import (
    "context"
    "testing"
    
    "github.com/hashicorp/terraform-plugin-framework/path"
    "github.com/hashicorp/terraform-plugin-framework/schema/validator"
    "github.com/hashicorp/terraform-plugin-framework/types"
    "github.com/stretchr/testify/assert"
)

func TestCacheEngineValidator(t *testing.T) {
    tests := []struct {
        name      string
        value     types.String
        wantError bool
    }{
        {
            name:      "valid redis",
            value:     types.StringValue("redis"),
            wantError: false,
        },
        {
            name:      "valid memcached",
            value:     types.StringValue("memcached"),
            wantError: false,
        },
        {
            name:      "invalid engine",
            value:     types.StringValue("mysql"),
            wantError: true,
        },
        {
            name:      "null value",
            value:     types.StringNull(),
            wantError: false,
        },
        {
            name:      "unknown value",
            value:     types.StringUnknown(),
            wantError: false,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := validator.StringRequest{
                Path:        path.Root("engine"),
                ConfigValue: tt.value,
            }
            resp := &validator.StringResponse{}
            
            CacheEngine().ValidateString(context.Background(), req, resp)
            
            if tt.wantError {
                assert.True(t, resp.Diagnostics.HasError())
            } else {
                assert.False(t, resp.Diagnostics.HasError())
            }
        })
    }
}

Step 2: Create Mock Client

// internal/client/mock_client.go
package client

import (
    "context"
    "fmt"
    
    "github.com/stretchr/testify/mock"
)

type MockClient struct {
    mock.Mock
}

func (m *MockClient) GetCache(ctx context.Context, id string) (*Cache, error) {
    args := m.Called(ctx, id)
    
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    
    return args.Get(0).(*Cache), args.Error(1)
}

func (m *MockClient) CreateCache(ctx context.Context, input CreateCacheInput) (*Cache, error) {
    args := m.Called(ctx, input)
    
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    
    return args.Get(0).(*Cache), args.Error(1)
}

func (m *MockClient) UpdateCache(ctx context.Context, id string, input UpdateCacheInput) (*Cache, error) {
    args := m.Called(ctx, id, input)
    
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    
    return args.Get(0).(*Cache), args.Error(1)
}

func (m *MockClient) DeleteCache(ctx context.Context, id string) error {
    args := m.Called(ctx, id)
    return args.Error(0)
}

func (m *MockClient) ListCaches(ctx context.Context, filters map[string]string) ([]Cache, error) {
    args := m.Called(ctx, filters)
    
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    
    return args.Get(0).([]Cache), args.Error(1)
}

Step 3: Create Shared Test Helpers

// internal/acctest/acctest.go
package acctest

import (
    "fmt"
    "os"
    "testing"
    "time"
    
    "github.com/example/terraform-provider-cachecloud/internal/client"
    "github.com/example/terraform-provider-cachecloud/internal/provider"
    "github.com/hashicorp/terraform-plugin-framework/providerserver"
    "github.com/hashicorp/terraform-plugin-go/tfprotov6"
    "github.com/hashicorp/terraform-plugin-testing/helper/acctest"
)

var ProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
    "cachecloud": providerserver.NewProtocol6WithError(provider.New("test")()),
}

func PreCheck(t *testing.T) {
    t.Helper()
    
    if v := os.Getenv("CACHECLOUD_API_KEY"); v == "" {
        t.Fatal("CACHECLOUD_API_KEY must be set for acceptance tests")
    }
}

func RandomName(prefix string) string {
    return acctest.RandomWithPrefix(prefix)
}

Test Fixtures

// internal/acctest/fixtures.go
package acctest

import (
    "fmt"
    "time"
    
    "github.com/example/terraform-provider-cachecloud/internal/client"
)

func CacheFixture(id, name string) *client.Cache {
    return &client.Cache{
        ID:            id,
        Name:          name,
        Engine:        "redis",
        EngineVersion: "7.0",
        NodeType:      "cache.t3.micro",
        NumNodes:      1,
        Port:          6379,
        Status:        "available",
        Endpoint:      fmt.Sprintf("%s.cache.example.com:6379", id),
        CreatedAt:     time.Now().Add(-1 * time.Hour),
        UpdatedAt:     time.Now(),
    }
}

func CacheConfigBasic(name string) string {
    return fmt.Sprintf(`
resource "cachecloud_cache" "test" {
  name      = %[1]q
  engine    = "redis"
  node_type = "cache.t3.micro"
  num_nodes = 1
}
`, name)
}

func CacheConfigWithTags(name string, tags map[string]string) string {
    tagConfig := ""
    if len(tags) > 0 {
        tagConfig = "\n  tags = {\n"
        for k, v := range tags {
            tagConfig += fmt.Sprintf("    %s = %q\n", k, v)
        }
        tagConfig += "  }"
    }
    
    return fmt.Sprintf(`
resource "cachecloud_cache" "test" {
  name      = %[1]q
  engine    = "redis"
  node_type = "cache.t3.micro"
  num_nodes = 1%[2]s
}
`, name, tagConfig)
}

Step 4: Write Acceptance Tests

// internal/provider/cache_resource_test.go
package provider

import (
    "fmt"
    "regexp"
    "testing"
    
    "github.com/example/terraform-provider-cachecloud/internal/acctest"
    "github.com/hashicorp/terraform-plugin-testing/helper/resource"
    "github.com/hashicorp/terraform-plugin-testing/terraform"
)

func TestAccCacheResource_basic(t *testing.T) {
    rName := acctest.RandomName("tf-acc-test")
    resourceName := "cachecloud_cache.test"
    
    resource.Test(t, resource.TestCase{
        PreCheck:                 func() { acctest.PreCheck(t) },
        ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories,
        CheckDestroy:             testAccCheckCacheDestroy,
        Steps: []resource.TestStep{
            {
                Config: acctest.CacheConfigBasic(rName),
                Check: resource.ComposeAggregateTestCheckFunc(
                    testAccCheckCacheExists(resourceName),
                    resource.TestCheckResourceAttr(resourceName, "name", rName),
                    resource.TestCheckResourceAttr(resourceName, "engine", "redis"),
                    resource.TestCheckResourceAttr(resourceName, "node_type", "cache.t3.micro"),
                    resource.TestCheckResourceAttr(resourceName, "num_nodes", "1"),
                    resource.TestCheckResourceAttrSet(resourceName, "id"),
                    resource.TestCheckResourceAttrSet(resourceName, "endpoint"),
                    resource.TestCheckResourceAttr(resourceName, "status", "available"),
                ),
            },
        },
    })
}

func TestAccCacheResource_update(t *testing.T) {
    rName := acctest.RandomName("tf-acc-test")
    resourceName := "cachecloud_cache.test"
    
    resource.Test(t, resource.TestCase{
        PreCheck:                 func() { acctest.PreCheck(t) },
        ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories,
        CheckDestroy:             testAccCheckCacheDestroy,
        Steps: []resource.TestStep{
            {
                Config: testAccCacheResourceConfig_numNodes(rName, 1),
                Check: resource.ComposeTestCheckFunc(
                    resource.TestCheckResourceAttr(resourceName, "num_nodes", "1"),
                ),
            },
            {
                Config: testAccCacheResourceConfig_numNodes(rName, 3),
                Check: resource.ComposeTestCheckFunc(
                    resource.TestCheckResourceAttr(resourceName, "num_nodes", "3"),
                ),
            },
        },
    })
}

func TestAccCacheResource_tags(t *testing.T) {
    rName := acctest.RandomName("tf-acc-test")
    resourceName := "cachecloud_cache.test"
    
    resource.Test(t, resource.TestCase{
        PreCheck:                 func() { acctest.PreCheck(t) },
        ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories,
        CheckDestroy:             testAccCheckCacheDestroy,
        Steps: []resource.TestStep{
            {
                Config: acctest.CacheConfigWithTags(rName, map[string]string{
                    "Environment": "test",
                }),
                Check: resource.ComposeTestCheckFunc(
                    resource.TestCheckResourceAttr(resourceName, "tags.%", "1"),
                    resource.TestCheckResourceAttr(resourceName, "tags.Environment", "test"),
                ),
            },
            {
                Config: acctest.CacheConfigWithTags(rName, map[string]string{
                    "Environment": "production",
                    "Team":        "platform",
                }),
                Check: resource.ComposeTestCheckFunc(
                    resource.TestCheckResourceAttr(resourceName, "tags.%", "2"),
                    resource.TestCheckResourceAttr(resourceName, "tags.Environment", "production"),
                    resource.TestCheckResourceAttr(resourceName, "tags.Team", "platform"),
                ),
            },
        },
    })
}

func TestAccCacheResource_import(t *testing.T) {
    rName := acctest.RandomName("tf-acc-test")
    resourceName := "cachecloud_cache.test"
    
    resource.Test(t, resource.TestCase{
        PreCheck:                 func() { acctest.PreCheck(t) },
        ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories,
        CheckDestroy:             testAccCheckCacheDestroy,
        Steps: []resource.TestStep{
            {
                Config: acctest.CacheConfigBasic(rName),
            },
            {
                ResourceName:      resourceName,
                ImportState:       true,
                ImportStateVerify: true,
            },
        },
    })
}

func TestAccCacheResource_invalidEngine(t *testing.T) {
    resource.Test(t, resource.TestCase{
        PreCheck:                 func() { acctest.PreCheck(t) },
        ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories,
        Steps: []resource.TestStep{
            {
                Config:      testAccCacheResourceConfig_invalidEngine(),
                ExpectError: regexp.MustCompile(`Engine must be either 'redis' or 'memcached'`),
            },
        },
    })
}

// Helper functions

func testAccCheckCacheExists(resourceName string) resource.TestCheckFunc {
    return func(s *terraform.State) error {
        rs, ok := s.RootModule().Resources[resourceName]
        if !ok {
            return fmt.Errorf("Not found: %s", resourceName)
        }
        
        if rs.Primary.ID == "" {
            return fmt.Errorf("No Cache ID is set")
        }
        
        // In real test, would call API to verify existence
        return nil
    }
}

func testAccCheckCacheDestroy(s *terraform.State) error {
    for _, rs := range s.RootModule().Resources {
        if rs.Type != "cachecloud_cache" {
            continue
        }
        
        // In real test, would verify cache is deleted via API
        // For now, assume it's destroyed
    }
    
    return nil
}

// Configuration helpers

func testAccCacheResourceConfig_numNodes(name string, numNodes int) string {
    return fmt.Sprintf(`
resource "cachecloud_cache" "test" {
  name      = %[1]q
  engine    = "redis"
  node_type = "cache.t3.micro"
  num_nodes = %[2]d
}
`, name, numNodes)
}

func testAccCacheResourceConfig_invalidEngine() string {
    return `
resource "cachecloud_cache" "test" {
  name      = "test-cache"
  engine    = "mysql"
  node_type = "cache.t3.micro"
  num_nodes = 1
}
`
}

Step 5: Run Tests

Run Unit Tests

# Run all unit tests
go test ./internal/provider/... -v

# Run specific test
go test ./internal/provider -run TestCacheEngineValidator -v

# Run with coverage
go test ./internal/provider/... -cover

Run Acceptance Tests

# Set required environment variables
export CACHECLOUD_API_KEY="your-api-key"
export CACHECLOUD_ENDPOINT="https://api.cachecloud.example.com"

# Run acceptance tests
TF_ACC=1 go test ./internal/provider -v -timeout=30m

# Run specific test
TF_ACC=1 go test ./internal/provider -run TestAccCacheResource_basic -v

# Run with parallel execution
TF_ACC=1 go test ./internal/provider -v -parallel=4

Step 6: Coverage Report

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

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

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

Lab Results

After completing this lab, you’ll have:

  • ✅ Unit tests for validators
  • ✅ Mock client for isolated testing
  • ✅ Acceptance tests for CRUD operations
  • ✅ Import and update tests
  • ✅ Shared test helpers and fixtures
  • ✅ Organized test structure
  • ✅ Coverage reporting

Troubleshooting

Tests Fail to Find Provider

Problem: provider "cachecloud" not found

Solution: Ensure ProtoV6ProviderFactories is set correctly:

ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){
    "cachecloud": providerserver.NewProtocol6WithError(New("test")()),
}

Acceptance Tests Skip

Problem: Tests skip with “TF_ACC not set”

Solution: Set environment variable:

export TF_ACC=1

Tests Timeout

Problem: Tests timeout before completion

Solution: Increase timeout:

go test ./... -timeout=60m

Best Practices Demonstrated

  1. Organized Structure: Separate unit, integration, and acceptance tests
  2. Shared Utilities: Common helpers in acctest package
  3. Test Fixtures: Reusable test data and configurations
  4. Mocking: Mock client for isolated unit tests
  5. Complete Coverage: CRUD, import, update, validation tests
  6. Clear Naming: Test names describe what they test
  7. Proper Cleanup: CheckDestroy verifies resource deletion

Key Takeaways

  • ✅ Organize tests by type (unit, integration, acceptance)
  • ✅ Create shared test package for helpers
  • ✅ Use mock clients for unit tests
  • ✅ Test full CRUD lifecycle
  • ✅ Include import and update tests
  • ✅ Test validation and error scenarios
  • ✅ Generate coverage reports
  • ✅ Keep tests maintainable with fixtures

What’s Next?

Practice your testing skills with exercises and challenges.

Next: Testing Practice →


Previous: ← Test Fixtures & Helpers

Last updated: July 3, 2026

Updated: