Testing Practice & Exercises
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