Advanced Testing Patterns

8 minute read

Advanced Testing Overview

Beyond basic unit and acceptance tests, advanced patterns ensure comprehensive coverage of complex scenarios, edge cases, and production conditions.

State Migration Testing

Testing State Upgraders

func TestCacheResource_StateUpgrade_V1ToV2(t *testing.T) {
    ctx := context.Background()
    
    // Create resource instance
    r := NewCacheResource()
    
    // Get state upgraders
    upgraders := r.UpgradeState(ctx)
    upgrader, exists := upgraders[1]
    if !exists {
        t.Fatal("No upgrader found for version 1")
    }
    
    // Create v1 state
    v1StateJSON := `{
        "id": "cache-123",
        "name": "test-cache",
        "instance_type": "cache.t3.micro",
        "engine": "redis"
    }`
    
    var v1State tfsdk.State
    // Parse v1 state...
    
    // Execute upgrade
    req := resource.UpgradeStateRequest{
        State: &v1State,
    }
    resp := &resource.UpgradeStateResponse{
        State: tfsdk.State{
            Schema: r.schema, // v2 schema
        },
    }
    
    upgrader.StateUpgrader(ctx, req, resp)
    
    // Verify upgrade
    if resp.Diagnostics.HasError() {
        t.Fatalf("Upgrade failed: %v", resp.Diagnostics)
    }
    
    var v2State CacheResourceModel
    resp.State.Get(ctx, &v2State)
    
    // Verify migrated fields
    assert.Equal(t, "cache-123", v2State.ID.ValueString())
    assert.Equal(t, "test-cache", v2State.Name.ValueString())
    assert.Equal(t, "cache.t3.micro", v2State.NodeType.ValueString()) // Renamed
    assert.Equal(t, "redis", v2State.Engine.ValueString())
    
    // Verify new field has default
    assert.False(t, v2State.EngineVersion.IsNull())
}

Integration Test with State Migration

func TestCacheResource_StateMigration_Integration(t *testing.T) {
    resource.Test(t, resource.TestCase{
        Steps: []resource.TestStep{
            {
                // Step 1: Create with old provider version
                ExternalProviders: map[string]resource.ExternalProvider{
                    "cachecloud": {
                        VersionConstraint: "1.0.0", // Old version
                        Source:            "example/cachecloud",
                    },
                },
                Config: `
resource "cachecloud_cache" "test" {
  name          = "migration-test"
  instance_type = "cache.t3.micro"
  engine        = "redis"
}
`,
                Check: resource.ComposeTestCheckFunc(
                    resource.TestCheckResourceAttr("cachecloud_cache.test", "instance_type", "cache.t3.micro"),
                ),
            },
            {
                // Step 2: Upgrade to new provider version
                ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
                Config: `
resource "cachecloud_cache" "test" {
  name      = "migration-test"
  node_type = "cache.t3.micro"  # Renamed attribute
  engine    = "redis"
}
`,
                Check: resource.ComposeTestCheckFunc(
                    resource.TestCheckResourceAttr("cachecloud_cache.test", "node_type", "cache.t3.micro"),
                    resource.TestCheckResourceAttr("cachecloud_cache.test", "engine", "redis"),
                    // Verify new computed field
                    resource.TestCheckResourceAttrSet("cachecloud_cache.test", "engine_version"),
                ),
            },
        },
    })
}

Complex Scenario Testing

Multi-Resource Dependencies

func TestComplexInfrastructure(t *testing.T) {
    resource.Test(t, resource.TestCase{
        ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
        Steps: []resource.TestStep{
            {
                Config: `
resource "cachecloud_cluster" "main" {
  name = "test-cluster"
  
  nodes = [
    {
      availability_zone = "us-west-2a"
      instance_type     = "cache.t3.micro"
    },
    {
      availability_zone = "us-west-2b"
      instance_type     = "cache.t3.micro"
    }
  ]
}

resource "cachecloud_cache" "app1" {
  name       = "app1-cache"
  cluster_id = cachecloud_cluster.main.id
  node_type  = "cache.t3.small"
}

resource "cachecloud_cache" "app2" {
  name       = "app2-cache"
  cluster_id = cachecloud_cluster.main.id
  node_type  = "cache.t3.small"
}

resource "cachecloud_backup" "nightly" {
  cache_ids = [
    cachecloud_cache.app1.id,
    cachecloud_cache.app2.id,
  ]
  
  schedule       = "0 2 * * *"
  retention_days = 7
}
`,
                Check: resource.ComposeTestCheckFunc(
                    // Verify cluster
                    resource.TestCheckResourceAttr("cachecloud_cluster.main", "name", "test-cluster"),
                    resource.TestCheckResourceAttr("cachecloud_cluster.main", "nodes.#", "2"),
                    
                    // Verify caches reference cluster
                    resource.TestCheckResourceAttrPair(
                        "cachecloud_cache.app1", "cluster_id",
                        "cachecloud_cluster.main", "id",
                    ),
                    resource.TestCheckResourceAttrPair(
                        "cachecloud_cache.app2", "cluster_id",
                        "cachecloud_cluster.main", "id",
                    ),
                    
                    // Verify backup references both caches
                    resource.TestCheckResourceAttr("cachecloud_backup.nightly", "cache_ids.#", "2"),
                ),
            },
        },
    })
}

Error Recovery Testing

func TestCacheResource_ErrorRecovery(t *testing.T) {
    resource.Test(t, resource.TestCase{
        ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
        Steps: []resource.TestStep{
            {
                // Step 1: Create resource
                Config: `
resource "cachecloud_cache" "test" {
  name      = "error-test"
  node_type = "cache.t3.micro"
}
`,
            },
            {
                // Step 2: Inject error in update
                PreConfig: func() {
                    // Simulate API failure
                    testClient.InjectError("UpdateCache", errors.New("API unavailable"))
                },
                Config: `
resource "cachecloud_cache" "test" {
  name      = "error-test-updated"
  node_type = "cache.t3.micro"
}
`,
                ExpectError: regexp.MustCompile("API unavailable"),
            },
            {
                // Step 3: Verify state preserved and retry succeeds
                PreConfig: func() {
                    testClient.ClearErrors()
                },
                Config: `
resource "cachecloud_cache" "test" {
  name      = "error-test-updated"
  node_type = "cache.t3.micro"
}
`,
                Check: resource.ComposeTestCheckFunc(
                    resource.TestCheckResourceAttr("cachecloud_cache.test", "name", "error-test-updated"),
                ),
            },
        },
    })
}

Performance Testing

Benchmark Tests

func BenchmarkCacheResourceCreate(b *testing.B) {
    ctx := context.Background()
    client := newTestClient()
    r := NewCacheResource()
    r.Configure(ctx, configureTestProvider(client))
    
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        req := resource.CreateRequest{
            Plan: planWithCache(fmt.Sprintf("bench-cache-%d", i)),
        }
        resp := &resource.CreateResponse{}
        
        r.Create(ctx, req, resp)
        
        if resp.Diagnostics.HasError() {
            b.Fatalf("Create failed: %v", resp.Diagnostics)
        }
    }
}

func BenchmarkParallelReads(b *testing.B) {
    ctx := context.Background()
    client := newTestClient()
    
    // Create test cache
    cacheID := createTestCache(b, client)
    
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            _, err := client.GetCache(ctx, cacheID)
            if err != nil {
                b.Fatal(err)
            }
        }
    })
}

Load Testing

func TestHighVolumeOperations(t *testing.T) {
    if testing.Short() {
        t.Skip("Skipping load test in short mode")
    }
    
    ctx := context.Background()
    client := newTestClient()
    
    const numCaches = 100
    const concurrency = 10
    
    // Create many caches concurrently
    errChan := make(chan error, numCaches)
    semaphore := make(chan struct{}, concurrency)
    
    var wg sync.WaitGroup
    for i := 0; i < numCaches; i++ {
        wg.Add(1)
        go func(index int) {
            defer wg.Done()
            
            semaphore <- struct{}{}
            defer func() { <-semaphore }()
            
            cache := &Cache{
                Name:     fmt.Sprintf("load-test-%d", index),
                NodeType: "cache.t3.micro",
            }
            
            _, err := client.CreateCache(ctx, cache)
            if err != nil {
                errChan <- err
            }
        }(i)
    }
    
    wg.Wait()
    close(errChan)
    
    // Check for errors
    var errors []error
    for err := range errChan {
        errors = append(errors, err)
    }
    
    if len(errors) > 0 {
        t.Fatalf("Failed to create %d/%d caches: %v", len(errors), numCaches, errors[0])
    }
}

Integration Testing

External Service Integration

func TestRealAPIIntegration(t *testing.T) {
    if os.Getenv("TF_ACC") == "" {
        t.Skip("Acceptance tests skipped unless TF_ACC is set")
    }
    
    apiKey := os.Getenv("CACHECLOUD_API_KEY")
    if apiKey == "" {
        t.Skip("CACHECLOUD_API_KEY not set")
    }
    
    ctx := context.Background()
    client := NewClient(apiKey)
    
    // Create real resource
    cache := &Cache{
        Name:     fmt.Sprintf("integration-test-%d", time.Now().Unix()),
        NodeType: "cache.t3.micro",
        Engine:   "redis",
    }
    
    // Create
    created, err := client.CreateCache(ctx, cache)
    require.NoError(t, err)
    require.NotEmpty(t, created.ID)
    
    // Cleanup
    defer func() {
        client.DeleteCache(ctx, created.ID)
    }()
    
    // Read
    read, err := client.GetCache(ctx, created.ID)
    require.NoError(t, err)
    assert.Equal(t, cache.Name, read.Name)
    
    // Update
    read.Name = cache.Name + "-updated"
    updated, err := client.UpdateCache(ctx, read.ID, read)
    require.NoError(t, err)
    assert.Equal(t, read.Name, updated.Name)
    
    // Delete
    err = client.DeleteCache(ctx, updated.ID)
    require.NoError(t, err)
    
    // Verify deleted
    _, err = client.GetCache(ctx, updated.ID)
    assert.Error(t, err)
}

Contract Testing

func TestAPIContract(t *testing.T) {
    tests := []struct {
        name     string
        method   string
        path     string
        body     interface{}
        expected int
    }{
        {
            name:     "create cache",
            method:   "POST",
            path:     "/caches",
            body:     map[string]interface{}{"name": "test", "node_type": "cache.t3.micro"},
            expected: http.StatusCreated,
        },
        {
            name:     "get cache",
            method:   "GET",
            path:     "/caches/cache-123",
            expected: http.StatusOK,
        },
        {
            name:     "update cache",
            method:   "PUT",
            path:     "/caches/cache-123",
            body:     map[string]interface{}{"name": "updated"},
            expected: http.StatusOK,
        },
        {
            name:     "delete cache",
            method:   "DELETE",
            path:     "/caches/cache-123",
            expected: http.StatusNoContent,
        },
        {
            name:     "not found",
            method:   "GET",
            path:     "/caches/nonexistent",
            expected: http.StatusNotFound,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                // Verify request
                assert.Equal(t, tt.method, r.Method)
                assert.Equal(t, tt.path, r.URL.Path)
                
                // Send response
                w.WriteHeader(tt.expected)
            }))
            defer server.Close()
            
            client := NewClient("test-key")
            client.baseURL = server.URL
            
            // Make request
            var body io.Reader
            if tt.body != nil {
                jsonBody, _ := json.Marshal(tt.body)
                body = bytes.NewReader(jsonBody)
            }
            
            req, _ := http.NewRequest(tt.method, server.URL+tt.path, body)
            resp, err := client.httpClient.Do(req)
            
            require.NoError(t, err)
            assert.Equal(t, tt.expected, resp.StatusCode)
        })
    }
}

Chaos Testing

Simulating Failures

type ChaosClient struct {
    *Client
    failureRate float64 // 0.0 to 1.0
    mu          sync.RWMutex
}

func NewChaosClient(client *Client, failureRate float64) *ChaosClient {
    return &ChaosClient{
        Client:      client,
        failureRate: failureRate,
    }
}

func (c *ChaosClient) GetCache(ctx context.Context, id string) (*Cache, error) {
    c.mu.RLock()
    rate := c.failureRate
    c.mu.RUnlock()
    
    // Randomly inject failures
    if rand.Float64() < rate {
        return nil, errors.New("chaos: simulated network failure")
    }
    
    return c.Client.GetCache(ctx, id)
}

func TestChaosScenarios(t *testing.T) {
    tests := []struct {
        name        string
        failureRate float64
        expectError bool
    }{
        {
            name:        "no failures",
            failureRate: 0.0,
            expectError: false,
        },
        {
            name:        "occasional failures",
            failureRate: 0.2,
            expectError: false, // Should retry and succeed
        },
        {
            name:        "frequent failures",
            failureRate: 0.8,
            expectError: true, // Will exhaust retries
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            baseClient := newTestClient()
            chaosClient := NewChaosClient(baseClient, tt.failureRate)
            
            // Try operation multiple times
            var lastErr error
            for i := 0; i < 10; i++ {
                _, err := chaosClient.GetCache(context.Background(), "test-id")
                if err != nil {
                    lastErr = err
                } else {
                    lastErr = nil
                    break // Success
                }
            }
            
            if tt.expectError {
                assert.Error(t, lastErr)
            } else {
                assert.NoError(t, lastErr)
            }
        })
    }
}

Test Helpers and Utilities

Factory Functions

func testCacheResourceModel(name string) CacheResourceModel {
    return CacheResourceModel{
        Name:     types.StringValue(name),
        NodeType: types.StringValue("cache.t3.micro"),
        Engine:   types.StringValue("redis"),
    }
}

func testCacheWithOptions(opts ...func(*Cache)) *Cache {
    cache := &Cache{
        Name:     "test-cache",
        NodeType: "cache.t3.micro",
        Engine:   "redis",
    }
    
    for _, opt := range opts {
        opt(cache)
    }
    
    return cache
}

// Usage
func TestWithOptions(t *testing.T) {
    cache := testCacheWithOptions(
        func(c *Cache) { c.Name = "custom-name" },
        func(c *Cache) { c.NodeType = "cache.r6g.large" },
    )
    
    assert.Equal(t, "custom-name", cache.Name)
}

Assertion Helpers

func assertCacheEquals(t *testing.T, expected, actual *Cache) {
    t.Helper()
    assert.Equal(t, expected.ID, actual.ID, "ID mismatch")
    assert.Equal(t, expected.Name, actual.Name, "Name mismatch")
    assert.Equal(t, expected.NodeType, actual.NodeType, "NodeType mismatch")
    assert.Equal(t, expected.Engine, actual.Engine, "Engine mismatch")
}

func assertDiagnosticsHasError(t *testing.T, diags diag.Diagnostics, contains string) {
    t.Helper()
    if !diags.HasError() {
        t.Fatal("Expected diagnostics to have error")
    }
    
    found := false
    for _, d := range diags.Errors() {
        if strings.Contains(d.Summary(), contains) || strings.Contains(d.Detail(), contains) {
            found = true
            break
        }
    }
    
    if !found {
        t.Fatalf("Expected error containing '%s', got: %v", contains, diags)
    }
}

Test Organization

Table-Driven Tests

func TestValidation(t *testing.T) {
    tests := []struct {
        name      string
        input     CacheResourceModel
        wantError bool
        errorMsg  string
    }{
        {
            name: "valid config",
            input: CacheResourceModel{
                Name:     types.StringValue("test"),
                NodeType: types.StringValue("cache.t3.micro"),
            },
            wantError: false,
        },
        {
            name: "invalid node type",
            input: CacheResourceModel{
                Name:     types.StringValue("test"),
                NodeType: types.StringValue("invalid"),
            },
            wantError: true,
            errorMsg:  "invalid node type",
        },
        {
            name: "empty name",
            input: CacheResourceModel{
                Name:     types.StringValue(""),
                NodeType: types.StringValue("cache.t3.micro"),
            },
            wantError: true,
            errorMsg:  "name cannot be empty",
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            diags := validateCache(tt.input)
            
            if tt.wantError {
                assert.True(t, diags.HasError())
                assertDiagnosticsHasError(t, diags, tt.errorMsg)
            } else {
                assert.False(t, diags.HasError())
            }
        })
    }
}

Best Practices

1. Isolate Tests

// ✅ Good: Each test independent
func TestCreate(t *testing.T) {
    client := newTestClient()
    // Test logic
}

func TestUpdate(t *testing.T) {
    client := newTestClient()
    // Test logic
}

// ❌ Bad: Tests share state
var sharedClient *Client

func TestCreate(t *testing.T) {
    // Uses shared client
}

2. Use Subtests

// ✅ Good: Organized with subtests
func TestCacheOperations(t *testing.T) {
    t.Run("Create", func(t *testing.T) { /* ... */ })
    t.Run("Read", func(t *testing.T) { /* ... */ })
    t.Run("Update", func(t *testing.T) { /* ... */ })
    t.Run("Delete", func(t *testing.T) { /* ... */ })
}

3. Clean Up Resources

// ✅ Good: Always cleanup
func TestResource(t *testing.T) {
    cache := createTestCache(t)
    defer cleanupCache(t, cache.ID)
    
    // Test logic
}

Key Takeaways

  • ✅ Test state migrations thoroughly
  • ✅ Cover complex multi-resource scenarios
  • ✅ Benchmark performance-critical paths
  • ✅ Test integration with real APIs
  • ✅ Simulate failures with chaos testing
  • ✅ Use helpers for common patterns
  • ✅ Organize with table-driven tests
  • ✅ Ensure test isolation and cleanup

What’s Next?

Apply all advanced techniques in a comprehensive hands-on lab.

Next: Advanced Lab →


Previous: ← Provider Middleware
Next: Advanced Lab →

Last updated: July 1, 2026

Updated: