Testing Practice & Exercises

8 minute read

Practice Overview

Apply your testing knowledge with hands-on exercises covering unit tests, mocks, and acceptance tests.

Exercise 1: Unit Test Validators

Task

Create a validator for node types and write comprehensive unit tests.

Requirements:

  • Valid node types: cache.t3.micro, cache.t3.small, cache.t3.medium
  • Should accept null/unknown values
  • Provide clear error messages

Starter Code

// node_type_validator.go
package provider

type nodeTypeValidator struct{}

func NodeType() validator.String {
    return nodeTypeValidator{}
}

// TODO: Implement Description
// TODO: Implement ValidateString

Solution

Click to reveal solution ```go // node_type_validator.go package provider import ( "context" "fmt" "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) var validNodeTypes = []string{ "cache.t3.micro", "cache.t3.small", "cache.t3.medium", } type nodeTypeValidator struct{} func (v nodeTypeValidator) Description(ctx context.Context) string { return fmt.Sprintf("value must be one of: %v", validNodeTypes) } func (v nodeTypeValidator) MarkdownDescription(ctx context.Context) string { return v.Description(ctx) } func (v nodeTypeValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() { return } value := req.ConfigValue.ValueString() for _, valid := range validNodeTypes { if value == valid { return } } resp.Diagnostics.AddAttributeError( req.Path, "Invalid Node Type", fmt.Sprintf("Node type must be one of %v, got: %s", validNodeTypes, value), ) } func NodeType() validator.String { return nodeTypeValidator{} } ``` ```go // node_type_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 TestNodeTypeValidator(t *testing.T) { tests := []struct { name string value types.String wantError bool }{ { name: "valid t3.micro", value: types.StringValue("cache.t3.micro"), wantError: false, }, { name: "valid t3.small", value: types.StringValue("cache.t3.small"), wantError: false, }, { name: "valid t3.medium", value: types.StringValue("cache.t3.medium"), wantError: false, }, { name: "invalid node type", value: types.StringValue("cache.t3.large"), 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("node_type"), ConfigValue: tt.value, } resp := &validator.StringResponse{} NodeType().ValidateString(context.Background(), req, resp) if tt.wantError { assert.True(t, resp.Diagnostics.HasError()) } else { assert.False(t, resp.Diagnostics.HasError()) } }) } } ```

Exercise 2: Mock Client Testing

Task

Create a mock client and test the Create operation of a Cache resource.

Requirements:

  • Mock successful cache creation
  • Mock API error scenario
  • Verify correct API input
  • Verify response handling

Starter Code

// TODO: Create MockClient
// TODO: Test successful creation
// TODO: Test error handling

Solution

Click to reveal solution ```go // mock_client.go package provider import ( "context" "github.com/stretchr/testify/mock" ) type MockClient struct { mock.Mock } func (m *MockClient) CreateCache(ctx context.Context, input client.CreateCacheInput) (*client.Cache, error) { args := m.Called(ctx, input) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*client.Cache), args.Error(1) } // cache_resource_unit_test.go package provider import ( "context" "fmt" "testing" "github.com/example/provider/internal/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) func TestCacheResource_Create_Success(t *testing.T) { mockClient := new(MockClient) expectedInput := client.CreateCacheInput{ Name: "test-cache", Engine: "redis", NodeType: "cache.t3.micro", NumNodes: 1, } expectedCache := &client.Cache{ ID: "cache-123", Name: "test-cache", Engine: "redis", NodeType: "cache.t3.micro", NumNodes: 1, Status: "available", } mockClient.On("CreateCache", mock.Anything, expectedInput). Return(expectedCache, nil) // Create would be called here with mockClient cache, err := mockClient.CreateCache(context.Background(), expectedInput) assert.NoError(t, err) assert.Equal(t, "cache-123", cache.ID) assert.Equal(t, "test-cache", cache.Name) mockClient.AssertExpectations(t) } func TestCacheResource_Create_APIError(t *testing.T) { mockClient := new(MockClient) mockClient.On("CreateCache", mock.Anything, mock.Anything). Return(nil, fmt.Errorf("API error: rate limit exceeded")) _, err := mockClient.CreateCache(context.Background(), client.CreateCacheInput{ Name: "test-cache", }) assert.Error(t, err) assert.Contains(t, err.Error(), "rate limit exceeded") mockClient.AssertExpectations(t) } ```

Exercise 3: Acceptance Test

Task

Write an acceptance test for updating cache tags.

Requirements:

  • Create cache with initial tags
  • Update tags (add, modify, remove)
  • Verify tag changes at each step

Starter Code

func TestAccCacheResource_tags(t *testing.T) {
    // TODO: Implement test
}

func testAccCacheResourceConfig_tags(name string, tags map[string]string) string {
    // TODO: Implement config builder
}

Solution

Click to reveal solution ```go func TestAccCacheResource_tags(t *testing.T) { rName := acctest.RandomWithPrefix("tf-acc-test") resourceName := "cachecloud_cache.test" resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, CheckDestroy: testAccCheckCacheDestroy, Steps: []resource.TestStep{ // Create with initial tags { Config: testAccCacheResourceConfig_tags(rName, map[string]string{ "Environment": "dev", "Project": "test", }), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr(resourceName, "tags.%", "2"), resource.TestCheckResourceAttr(resourceName, "tags.Environment", "dev"), resource.TestCheckResourceAttr(resourceName, "tags.Project", "test"), ), }, // Update tags - modify and add { Config: testAccCacheResourceConfig_tags(rName, map[string]string{ "Environment": "production", "Project": "test", "Owner": "platform-team", }), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr(resourceName, "tags.%", "3"), resource.TestCheckResourceAttr(resourceName, "tags.Environment", "production"), resource.TestCheckResourceAttr(resourceName, "tags.Owner", "platform-team"), ), }, // Remove all tags { Config: testAccCacheResourceConfig_basic(rName), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr(resourceName, "tags.%", "0"), ), }, }, }) } func testAccCacheResourceConfig_tags(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) } func testAccCacheResourceConfig_basic(name string) string { return fmt.Sprintf(` resource "cachecloud_cache" "test" { name = %[1]q engine = "redis" node_type = "cache.t3.micro" num_nodes = 1 } `, name) } ```

Exercise 4: Test Fixtures

Task

Create reusable test fixtures and builders for Cache test data.

Requirements:

  • Basic cache fixture
  • Cache builder with fluent interface
  • Configuration builder for common scenarios

Solution

Click to reveal solution ```go // fixtures.go package acctest import ( "fmt" "time" "github.com/example/provider/internal/client" ) // Basic fixture func CacheFixture() *client.Cache { return &client.Cache{ ID: "cache-123", Name: "test-cache", Engine: "redis", EngineVersion: "7.0", NodeType: "cache.t3.micro", NumNodes: 1, Port: 6379, Status: "available", Endpoint: "cache-123.cache.example.com:6379", CreatedAt: time.Now().Add(-1 * time.Hour), UpdatedAt: time.Now(), } } // Builder pattern type CacheBuilder struct { cache *client.Cache } func NewCacheBuilder() *CacheBuilder { return &CacheBuilder{ cache: CacheFixture(), } } func (b *CacheBuilder) WithID(id string) *CacheBuilder { b.cache.ID = id return b } func (b *CacheBuilder) WithName(name string) *CacheBuilder { b.cache.Name = name return b } func (b *CacheBuilder) WithEngine(engine string) *CacheBuilder { b.cache.Engine = engine return b } func (b *CacheBuilder) WithNodes(n int) *CacheBuilder { b.cache.NumNodes = n return b } func (b *CacheBuilder) WithStatus(status string) *CacheBuilder { b.cache.Status = status return b } func (b *CacheBuilder) Build() *client.Cache { return b.cache } // Configuration builders 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 CacheConfigMultiNode(name string, nodes int) string { return fmt.Sprintf(` resource "cachecloud_cache" "test" { name = %[1]q engine = "redis" node_type = "cache.t3.small" num_nodes = %[2]d } `, name, nodes) } func CacheConfigWithVPC(name, vpcCIDR, subnetCIDR string) string { return fmt.Sprintf(` resource "cachecloud_vpc" "test" { name = "%[1]s-vpc" cidr = %[2]q } resource "cachecloud_subnet" "test" { vpc_id = cachecloud_vpc.test.id cidr = %[3]q } resource "cachecloud_cache" "test" { name = %[1]q engine = "redis" node_type = "cache.t3.micro" num_nodes = 1 vpc_id = cachecloud_vpc.test.id subnet_id = cachecloud_subnet.test.id } `, name, vpcCIDR, subnetCIDR) } // Usage example func TestCacheBuilderUsage(t *testing.T) { // Using builder cache := NewCacheBuilder(). WithName("my-cache"). WithNodes(3). WithStatus("creating"). Build() assert.Equal(t, "my-cache", cache.Name) assert.Equal(t, 3, cache.NumNodes) assert.Equal(t, "creating", cache.Status) // Using config builder config := CacheConfigMultiNode("test-cache", 5) // Use config in acceptance test } ```

Challenge Problems

Challenge 1: Test Data Source with Filters

Create an acceptance test for a data source that supports multiple filters.

Requirements:

  • Create multiple caches with different attributes
  • Test filtering by engine type
  • Test filtering by tags
  • Test combining multiple filters

Challenge 2: Test Import with Composite ID

Implement and test import for a resource with composite ID format: region/cache-id.

Requirements:

  • Implement ImportState function
  • Parse composite ID
  • Write acceptance test for import
  • Handle import errors gracefully

Challenge 3: Test Update with ForceNew

Test an update that requires resource replacement.

Requirements:

  • Identify attributes that force replacement
  • Capture resource IDs before and after update
  • Verify old resource is destroyed
  • Verify new resource is created with updated attributes

Quiz Questions

Question 1

What’s the difference between unit tests and acceptance tests?

Answer **Unit Tests:** - Test individual functions/components in isolation - Use mocks/stubs for dependencies - Fast execution (milliseconds) - Don't require external resources - Run during development **Acceptance Tests:** - Test complete provider functionality - Use real API and infrastructure - Slower execution (minutes) - Require configured provider and credentials - Run before releases Both are important for comprehensive testing.

Question 2

When should you use TestCheckResourceAttrPair vs TestCheckResourceAttr?

Answer **Use TestCheckResourceAttrPair when:** - Comparing attributes between two resources - Verifying data source matches resource - Testing relationships between resources - Example: `resource.TestCheckResourceAttrPair(dataSourceName, "id", resourceName, "id")` **Use TestCheckResourceAttr when:** - Verifying attribute has specific value - Checking computed attributes are set - Testing string/number values - Example: `resource.TestCheckResourceAttr(resourceName, "engine", "redis")`

Question 3

What should be included in a CheckDestroy function?

Answer A `CheckDestroy` function should: 1. Iterate through all resources in state 2. Filter for your resource type 3. Attempt to read each resource via API 4. Verify resource is not found 5. Return error if resource still exists 6. Distinguish between "not found" and other errors ```go func testAccCheckCacheDestroy(s *terraform.State) error { conn := testAccProvider.Meta().(*client.Client) for _, rs := range s.RootModule().Resources { if rs.Type != "cachecloud_cache" { continue } _, err := conn.GetCache(context.Background(), rs.Primary.ID) if err == nil { return fmt.Errorf("Cache %s still exists", rs.Primary.ID) } if !client.IsNotFoundError(err) { return err } } return nil } ```

Best Practices Checklist

When writing tests, ensure you:

  • ✅ Use descriptive test names
  • ✅ Test one thing per test
  • ✅ Use table-driven tests for multiple cases
  • ✅ Mark helper functions with t.Helper()
  • ✅ Generate unique resource names
  • ✅ Include CheckDestroy in acceptance tests
  • ✅ Test import functionality
  • ✅ Test update scenarios
  • ✅ Test error cases
  • ✅ Keep tests independent
  • ✅ Clean up test resources
  • ✅ Document complex test scenarios

Additional Resources

Testing Documentation

Example Providers

Study testing patterns in official providers:

Key Takeaways

  • ✅ Practice unit tests with validators and helpers
  • ✅ Use mocks for isolated testing
  • ✅ Write comprehensive acceptance tests
  • ✅ Create reusable test fixtures
  • ✅ Test CRUD, import, and updates
  • ✅ Verify error handling
  • ✅ Maintain test code quality
  • ✅ Document test scenarios
  • ✅ Follow testing best practices

What’s Next?

Continue to Module 11 to learn about validation and error handling patterns.

Next Module: Validation & Error Handling →


Previous: ← Testing Lab

Last updated: July 3, 2026

Updated: