Advanced Testing Patterns
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.
Previous: ← Provider Middleware
Next: Advanced Lab →
Last updated: July 1, 2026