// Comprehensive guide for designing and implementing Terraform providers using Test-Driven Development (TDD). Use when creating new Terraform providers, adding resources/data sources to existing providers, implementing TDD workflows (RED-GREEN-REFACTOR), designing provider architecture, or working in terraform-provider-* directories. Covers HashiCorp best practices, Plugin Framework patterns, acceptance testing, CRUD implementations, and parallel development workflows.
| name | terraform-provider-design |
| description | Comprehensive guide for designing and implementing Terraform providers using Test-Driven Development (TDD). Use when creating new Terraform providers, adding resources/data sources to existing providers, implementing TDD workflows (RED-GREEN-REFACTOR), designing provider architecture, or working in terraform-provider-* directories. Covers HashiCorp best practices, Plugin Framework patterns, acceptance testing, CRUD implementations, and parallel development workflows. |
This skill guides Terraform provider development using Test-Driven Development (TDD) following HashiCorp's official best practices. It supports the complete RED-GREEN-REFACTOR cycle with parallel execution patterns for efficient provider development.
Use this skill when:
Provider development follows strict TDD phases executed in parallel where possible:
๐ด RED Phase: Write failing acceptance tests
๐ข GREEN Phase: Write minimal CRUD code
๐ REFACTOR Phase: Improve implementation
Execute multiple TDD cycles concurrently:
# RED PHASE - Write multiple failing tests in parallel
Write("internal/provider/instance_resource_test.go")
Write("internal/provider/network_resource_test.go")
Write("internal/provider/user_data_source_test.go")
Bash("TF_ACC=1 go test -v -timeout 120m ./internal/provider/")
# GREEN PHASE - Implement minimal CRUD in parallel
Write("internal/provider/instance_resource.go")
Write("internal/provider/network_resource.go")
Write("internal/provider/user_data_source.go")
Bash("TF_ACC=1 go test -v -timeout 120m ./internal/provider/")
# REFACTOR PHASE - Improve implementations in parallel
Edit("internal/provider/instance_resource.go")
Edit("internal/provider/network_resource.go")
Edit("internal/provider/user_data_source.go")
Bash("TF_ACC=1 go test -v -timeout 120m ./internal/provider/")
Create a new provider project:
# Initialize Go module
go mod init github.com/yourusername/terraform-provider-{name}
# Add required dependencies
go get github.com/hashicorp/terraform-plugin-framework
go get github.com/hashicorp/terraform-plugin-go
go get github.com/hashicorp/terraform-plugin-log
go get github.com/hashicorp/terraform-plugin-testing
# Create directory structure
mkdir -p internal/provider examples/{provider,resources,data-sources} docs
See assets/provider_template.go for a complete provider initialization example.
terraform-provider-{name}/
โโโ internal/
โ โโโ provider/
โ โโโ provider.go # Provider definition
โ โโโ provider_test.go # Provider tests
โ โโโ resource_*.go # Resource implementations
โ โโโ resource_*_test.go # Resource acceptance tests
โ โโโ data_source_*.go # Data source implementations
โ โโโ data_source_*_test.go # Data source acceptance tests
โโโ examples/
โ โโโ provider/ # Provider configuration examples
โ โโโ resources/ # Resource examples
โ โโโ data-sources/ # Data source examples
โโโ docs/ # Generated documentation
โโโ main.go # Provider binary entry point
โโโ go.mod # Go module dependencies
โโโ CHANGELOG.md # Version history
โโโ .goreleaser.yml # Release automation
โโโ GNUmakefile # Build and test commands
Follow HashiCorp's core design principles (see references/hashicorp_best_practices.md for details):
terraform importStart with the schema that matches the underlying API:
func (r *InstanceResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Instance resource",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Instance identifier",
},
"name": schema.StringAttribute{
Required: true,
MarkdownDescription: "Instance name",
},
"tags": schema.MapAttribute{
Optional: true,
ElementType: types.StringType,
MarkdownDescription: "Resource tags",
},
},
}
}
Create failing test before implementation:
func TestAccInstanceResource(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
// Create and Read
{
Config: testAccInstanceResourceConfig("test"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("example_instance.test", "name", "test"),
resource.TestCheckResourceAttrSet("example_instance.test", "id"),
),
},
// ImportState
{
ResourceName: "example_instance.test",
ImportState: true,
ImportStateVerify: true,
},
// Update and Read
{
Config: testAccInstanceResourceConfig("test-updated"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("example_instance.test", "name", "test-updated"),
),
},
},
})
}
Implement simplest CRUD to pass tests:
func (r *InstanceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data InstanceResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Minimal - hardcoded for now
data.ID = types.StringValue("instance-123")
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
Add real API integration:
func (r *InstanceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data InstanceResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Real API call
instance, err := r.client.CreateInstance(ctx, data.Name.ValueString())
if err != nil {
resp.Diagnostics.AddError(
"Error Creating Instance",
"Could not create instance: "+err.Error(),
)
return
}
data.ID = types.StringValue(instance.ID)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
Follow HashiCorp naming standards:
Resources: Singular nouns with provider prefix
{provider}_{resource_name}aws_instance, postgresql_databaseData Sources: Nouns (plural for collections)
{provider}_{data_source_name}aws_availability_zones, azurerm_subnetAttributes: Lowercase with underscores
instance_type)security_group_ids)auto_scaling_enabled)Functions: Verbs without provider prefix
{verb}_{object}parse_rfc3339, encode_base64Every acceptance test is built around resource.TestCase, the Go struct that defines the complete test lifecycle.
๐ COMPLETE GUIDE: See references/testcase_structure.md for comprehensive TestCase documentation
func TestAccResourceName(t *testing.T) {
resource.Test(t, resource.TestCase{
// Validate prerequisites
PreCheck: func() { testAccPreCheck(t) },
// Check Terraform version compatibility
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_8_0),
},
// Provider setup (REQUIRED)
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
// Verify cleanup after destroy (REQUIRED for resources)
CheckDestroy: testAccCheckResourceDestroy,
// Test steps (REQUIRED)
Steps: []resource.TestStep{
// Create, Import, Update, etc.
},
})
}
Key TestCase Fields:
Every resource MUST test:
๐ For complete TestCase structure details, see references/testcase_structure.md
๐ For comprehensive plan checks guidance, see references/plan_checks_guide.md
HashiCorp recommends four core testing patterns (see Testing Patterns):
ImportState: trueState checks validate resource attributes in Terraform state after terraform apply. They execute during Lifecycle (config) mode and provide comprehensive attribute verification.
Key Characteristics:
ExpectKnownValue - Verify Attribute Type and Value:
import (
"github.com/hashicorp/terraform-plugin-testing/statecheck"
"github.com/hashicorp/terraform-plugin-testing/tfjsonpath"
"github.com/hashicorp/terraform-plugin-testing/knownvalue"
)
// Basic attribute verification
StateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(
"example_instance.test",
tfjsonpath.New("name"),
knownvalue.StringExact("test-instance"),
),
statecheck.ExpectKnownValue(
"example_instance.test",
tfjsonpath.New("enabled"),
knownvalue.Bool(true),
),
}
// Nested attribute with map key access
StateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(
"example_instance.test",
tfjsonpath.New("tags").AtMapKey("environment"),
knownvalue.StringExact("production"),
),
}
// Understanding tfjson Paths
// Paths specify exact locations within Terraform JSON data structures
// Supporting hierarchical navigation:
// Root-level attribute
tfjsonpath.New("name")
// Nested block attribute
tfjsonpath.New("configuration").AtMapKey("setting1")
// List element
tfjsonpath.New("items").AtSliceIndex(0)
// Complex nested structure
tfjsonpath.New("network").AtMapKey("subnets").AtSliceIndex(0).AtMapKey("cidr")
// Builder Methods:
// - AtMapKey(key) - Access map values or nested attributes
// - AtSliceIndex(index) - Access list or set elements
CompareValue - Track Attribute Changes Across Steps:
// Compare computed values across test steps
compareValuesSame := statecheck.CompareValue(compare.ValuesSame())
// First test step - capture initial value
{
Config: testAccInstanceResourceConfig("test"),
ConfigStateChecks: []statecheck.StateCheck{
compareValuesSame.AddStateValue(
"example_instance.test",
tfjsonpath.New("computed_id"),
),
},
}
// Second test step - verify value hasn't changed
{
Config: testAccInstanceResourceConfig("test-updated"),
ConfigStateChecks: []statecheck.StateCheck{
compareValuesSame.AddStateValue(
"example_instance.test",
tfjsonpath.New("computed_id"),
),
},
}
Known Value Types:
// Boolean values
knownvalue.Bool(true)
// String values
knownvalue.StringExact("exact-match")
// Numeric values
knownvalue.Int64Exact(42)
knownvalue.Float64Exact(3.14)
// Null and existence checks
knownvalue.Null()
knownvalue.NotNull()
// Collections
knownvalue.ListExact([]knownvalue.Check{
knownvalue.StringExact("item1"),
knownvalue.StringExact("item2"),
})
knownvalue.MapExact(map[string]knownvalue.Check{
"key1": knownvalue.StringExact("value1"),
"key2": knownvalue.Int64Exact(100),
})
knownvalue.SetExact([]knownvalue.Check{
knownvalue.StringExact("elem1"),
knownvalue.StringExact("elem2"),
})
Sensitive Value Verification (Terraform 1.4.6+):
// Verify attribute is marked sensitive
StateChecks: []statecheck.StateCheck{
statecheck.ExpectSensitiveValue(
"example_instance.test",
tfjsonpath.New("api_key"),
),
}
State Check Best Practices:
ExpectKnownValue for exact value matchingCompareValue to track consistency across test stepsExpectSensitiveValue for sensitive attributesPlan checks are critical for validating Terraform plan behavior. They inspect plan files at specific phases to ensure expected operations.
๐ COMPREHENSIVE GUIDE: See references/plan_checks_guide.md for complete documentation
Plan checks validate Terraform plan outcomes during test execution. They ensure configuration changes produce expected planning results.
Key Characteristics:
Built-in Plan Check Functions:
import "github.com/hashicorp/terraform-plugin-testing/plancheck"
// ExpectEmptyPlan - Asserts no operations for apply
// Use when: Verifying idempotency or that updates produce no changes
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectEmptyPlan(),
},
}
// ExpectNonEmptyPlan - Asserts at least one operation for apply
// Use when: Confirming configuration changes trigger updates
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectNonEmptyPlan(),
},
}
Plan Check Example:
func TestAccInstanceResource_Idempotent(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
// Create
{
Config: testAccInstanceResourceConfig("test"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("example_instance.test", "name", "test"),
),
},
// Verify idempotency - no plan changes
{
Config: testAccInstanceResourceConfig("test"),
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectEmptyPlan(),
},
},
},
},
})
}
Plan Check Best Practices:
ExpectEmptyPlan() to verify idempotency after resource creationExpectNonEmptyPlan() to confirm updates actually trigger changesCombining state checks, plan checks, and traditional checks:
func TestAccInstanceResource_Complete(t *testing.T) {
compareID := statecheck.CompareValue(compare.ValuesSame())
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
// Create and verify
{
Config: testAccInstanceResourceConfig("test"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("example_instance.test", "name", "test"),
resource.TestCheckResourceAttrSet("example_instance.test", "id"),
),
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(
"example_instance.test",
tfjsonpath.New("name"),
knownvalue.StringExact("test"),
),
compareID.AddStateValue(
"example_instance.test",
tfjsonpath.New("id"),
),
},
},
// Verify idempotency
{
Config: testAccInstanceResourceConfig("test"),
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectEmptyPlan(),
},
},
},
// Update and verify ID remains same
{
Config: testAccInstanceResourceConfig("test-updated"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("example_instance.test", "name", "test-updated"),
),
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(
"example_instance.test",
tfjsonpath.New("name"),
knownvalue.StringExact("test-updated"),
),
// Verify ID hasn't changed
compareID.AddStateValue(
"example_instance.test",
tfjsonpath.New("id"),
),
},
},
},
})
}
When fixing bugs, follow this workflow:
This allows independent verification of problem reproduction before evaluating solutions
Create reusable test helpers for common operations:
Test Helpers File (internal/provider/test_helpers.go):
// createTestClient - Authenticate and return API client for tests
func createTestClient(t *testing.T) *APIClient {
endpoint := os.Getenv("API_ENDPOINT")
username := os.Getenv("API_USERNAME")
password := os.Getenv("API_PASSWORD")
if endpoint == "" || username == "" || password == "" {
t.Fatalf("API credentials not set")
}
client, err := NewAPIClient(context.Background(), endpoint, username, password)
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
return client
}
// getResourceIDByName - Query API to get resource ID by name
func getResourceIDByName(t *testing.T, service, method, name string) string {
client := createTestClient(t)
body, err := client.CallAPI(context.Background(), service, method, name)
if err != nil {
t.Fatalf("Failed to get resource: %v", err)
}
var resource map[string]interface{}
json.Unmarshal(body, &resource)
return resource["id"].(string)
}
// verifyResourceDeleted - Poll API with exponential backoff to verify deletion
func verifyResourceDeleted(ctx context.Context, client *APIClient,
service, method, id string, maxRetries int) (bool, error) {
for i := 0; i < maxRetries; i++ {
_, err := client.CallAPI(ctx, service, method, id)
if err != nil {
// Resource not found - successfully deleted
return true, nil
}
time.Sleep(time.Duration(1<<i) * time.Second) // Exponential backoff
}
return false, fmt.Errorf("resource still exists after %d retries", maxRetries)
}
// generateUniqueTestName - Create timestamped unique test names
func generateUniqueTestName(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, time.Now().Unix())
}
Implement CheckDestroy to verify resource cleanup:
func testAccCheckResourceDestroy(s *terraform.State) error {
client := createTestClient(&testing.T{})
ctx := context.Background()
for _, rs := range s.RootModule().Resources {
if rs.Type != "example_resource" {
continue
}
// Verify resource is deleted with exponential backoff
deleted, err := verifyResourceDeleted(ctx, client,
"ServiceName", "getMethod", rs.Primary.ID, 4)
if !deleted {
if err != nil {
return fmt.Errorf("error checking deletion: %w", err)
}
return fmt.Errorf("resource %s still exists", rs.Primary.ID)
}
}
return nil
}
// Add to TestCase
resource.Test(t, resource.TestCase{
CheckDestroy: testAccCheckResourceDestroy,
// ... rest of test
})
Comprehensive drift detection using test helpers:
func TestAccResource_DriftDetection(t *testing.T) {
name := generateUniqueTestName("test-drift")
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
// Step 1: Create resource
{
Config: testAccResourceConfig(name, "initial-value"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("example_resource.test", "attr", "initial-value"),
),
},
// Step 2: Modify externally (drift)
{
PreConfig: func() {
client := createTestClient(t)
ctx := context.Background()
// Get resource ID by name
id := getResourceIDByName(t, "Service", "getMethod", name)
// Fetch full resource data
body, _ := client.CallAPI(ctx, "Service", "getMethod", name)
var resourceData map[string]interface{}
json.Unmarshal(body, &resourceData)
// Modify field externally (note: snake_case โ camelCase mapping!)
resourceData["camelCaseField"] = "modified-value"
resourceData["id"] = id
// Update via API
client.CallAPI(ctx, "Service", "updateMethod", resourceData)
// Wait for eventual consistency
time.Sleep(2 * time.Second)
},
Config: testAccResourceConfig(name, "initial-value"),
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectNonEmptyPlan(),
},
},
},
// Step 3: Terraform restores desired state
{
Config: testAccResourceConfig(name, "initial-value"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("example_resource.test", "attr", "initial-value"),
),
},
},
})
}
Important Drift Detection Notes:
kernel_parameters โ kernelParameters)For comprehensive drift detection guidance, see references/drift_detection_guide.md
# Unit tests
go test -v ./...
# Acceptance tests
TF_ACC=1 go test -v -timeout 120m ./internal/provider/
# Parallel execution
TF_ACC=1 go test -v -parallel=4 -timeout 120m ./...
# Run specific test patterns
TF_ACC=1 go test -v -run "Drift" ./internal/provider/
# With detailed logging
TF_LOG=TRACE TF_ACC=1 go test -v ./internal/provider/
For sensitive data like tokens or secrets:
// Use ephemeral resources (Plugin Framework only)
// Data not persisted in state
Mark sensitive attributes:
"api_key": schema.StringAttribute{
Required: true,
Sensitive: true, // Prevents display in output
}
Note: Sensitive flag does NOT encrypt state files. Use remote backends with encryption at rest.
Generate provider documentation automatically:
go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate
Ensure examples/ directory contains:
provider/provider.tf - Provider configurationresources/{resource_name}/resource.tf - Resource examplesdata-sources/{data_source_name}/data-source.tf - Data source examplesFollow Semantic Versioning (MAJOR.MINOR.PATCH):
MAJOR - Breaking changes:
MINOR - New features:
PATCH - Bug fixes only
Recommendation: Major versions no more than once per year
Set up automated testing with GitHub Actions:
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: "1.21"
- run: go test -v -cover ./...
- run: TF_ACC=1 go test -v -timeout 120m ./internal/provider/
See references/ci_cd_patterns.md for:
Create isolated test environments:
func testAccPreCheck(t *testing.T) {
// Verify API credentials
if v := os.Getenv("EXAMPLE_API_KEY"); v == "" {
t.Fatal("EXAMPLE_API_KEY must be set for acceptance tests")
}
// Verify API endpoint
if v := os.Getenv("EXAMPLE_API_ENDPOINT"); v == "" {
t.Skip("EXAMPLE_API_ENDPOINT not set, skipping acceptance tests")
}
}
CRITICAL: Use separate accounts/namespaces for testing:
For terraform-plugin-testing v1.5.0+, always add root-level id attribute:
"id": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Resource identifier",
},
# Run tests in parallel (recommended 4-8)
TF_ACC=1 go test -v -parallel=4 -timeout 120m ./...
# Run specific test
TF_ACC=1 go test -v -run TestAccExampleResource ./internal/provider/
assets/)provider_template.go - Provider initialization and configuration templatemain_template.go - Provider binary entry point templateresource_template.go - Complete resource implementation templatedata_source_template.go - Data source implementation templateacceptance_test_template.go - Acceptance test template with all required stepsreferences/)Testing Fundamentals:
testcase_structure.md - Complete TestCase structure, all fields, execution flow, and best practicesplan_checks_guide.md - Comprehensive plan checks documentation with all types and patternstdd_patterns.md - TDD workflows, test helpers, CheckDestroy, and test structuresdrift_detection_guide.md - External modification testing with three-step patternImplementation Patterns:
api_client_patterns.md - API client architecture, authentication, retry logic, and error handlinghashicorp_best_practices.md - Official HashiCorp design principles and standardsci_cd_patterns.md - CI/CD workflows, automation, tooling, and release managementvar testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
"example": providerserver.NewProtocol6WithError(New("test")()),
}
func testAccPreCheck(t *testing.T) {
// Verify required environment variables
}
// Exact match
resource.TestCheckResourceAttr("example_instance.test", "name", "expected")
// Attribute exists
resource.TestCheckResourceAttrSet("example_instance.test", "id")
// Match between resources
resource.TestCheckResourceAttrPair("example_instance.test", "vpc_id", "example_vpc.test", "id")
// Combine checks
resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr(...),
resource.TestCheckResourceAttrSet(...),
)
โ Skipping ImportState tests
โ Skipping Drift tests
โ Skipping CheckDestroy implementation
โ Skipping idempotency tests with plan checks
โ Using hardcoded test values (use generateUniqueTestName() instead)
โ Incomplete CRUD testing
โ Ignoring error cases
โ Missing or outdated documentation
โ Not testing state drift
โ Tests dependent on external state
โ Not verifying plan outcomes with ExpectEmptyPlan/ExpectNonEmptyPlan
โ Using TestCheckResourceAttr for computed values (use StateChecks instead)
โ Not tracking attribute consistency across test steps (use CompareValue)
โ Missing sensitive attribute verification (use ExpectSensitiveValue)
โ Not documenting field name mappings (snake_case vs camelCase)
โ Missing test helper functions for repeated operations
โ Not using exponential backoff for eventual consistency
โ Duplicating client setup code across tests