Generated banner for Provider Test Patterns
Generated logo for Provider Test Patterns

Provider Test Patterns

HashiCorpOfficialPublished by HashiCorp

  • Skill
  • Guidelines
  • Software development
  • MPL-2.0
  • No issues found

From the author

Terraform provider acceptance test patterns using terraform-plugin-testing with the Plugin Framework. Covers test structure, TestCase/TestStep fields, ConfigStateChecks with custom statecheck.StateCheck implementations, plan checks, CompareValue for cross-step assertions, config helpers, import testing with ImportStateKind, sweepers, and scenario patterns (basic, update, disappears, validation, regression), and ephemeral resource testing with the echoprovider package. Use when writing, reviewing, or debugging provider acceptance tests, including questions about statecheck, plancheck, TestCheckFunc, CheckDestroy, ExpectError, import state verification, ephemeral resources, or how to structure test files.
— the description by HashiCorp

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides a comprehensive set of patterns and best practices for authoring acceptance tests for Terraform providers built with the Plugin Framework. It covers the lifecycle of test execution, the structure of test cases and steps, and modern assertion techniques using ConfigStateChecks. It also includes guidance on handling import testing, ephemeral resources, and regression testing, ensuring consistent and maintainable test suites.

What it does

  • Defines test lifecycle stages including plan, apply, refresh, and destroy.
  • Provides patterns for modern ConfigStateChecks and plan assertions.
  • Demonstrates how to implement custom state checks for API verification.
  • Outlines strategies for testing ephemeral resources using the echo provider.
  • Details import testing workflows and state verification.
  • Offers templates for regression testing and scenario-based test steps.

When to use it

  • When writing new acceptance tests for a Terraform provider.
  • When refactoring legacy tests to use modern Plugin Framework testing patterns.
  • When debugging failing provider tests related to state or plan drift.
  • When implementing tests for ephemeral resources or complex import scenarios.

Advantages

  • Promotes type-safe assertions with aggregated error reporting.
  • Ensures consistent test structure across provider codebases.
  • Facilitates easier reproduction of bugs through regression test patterns.
  • Provides clear separation between test logic and provider configuration.

Usage examples

“How do I assert that a resource attribute matches a specific value in a test step?”
Use the ConfigStateChecks field with statecheck.ExpectKnownValue, specifying the resource address, the attribute path via tfjsonpath, and the expected value type.
“What is the recommended way to verify that a resource is destroyed after a test?”
Implement a TestCheckFunc that queries the API for the resource ID and returns an error if the resource still exists, then assign this to the CheckDestroy field in the TestCase.
“How can I test an ephemeral resource?”
Use the echoprovider package to capture ephemeral data into a managed resource's state, then perform standard ConfigStateChecks on that managed resource.
“How should I structure a regression test for a GitHub issue?”
Create a dedicated test function that reproduces the specific configuration triggering the bug, include a reference to the issue, and use ConfigStateChecks to verify the fix.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/hashicorp/provider-test-patterns/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip provider-test-patterns.zip -d .agents/skills

Documentation by HashiCorp

Provider Acceptance Test Patterns

Patterns for writing acceptance tests using terraform-plugin-testing with the Plugin Framework.

Source: HashiCorp Testing Patterns

References (load when needed):

  • references/checks.md — statecheck, plancheck, knownvalue types, tfjsonpath, comparers

  • references/sweepers.md — sweeper setup, TestMain, dependencies

  • references/ephemeral.md — ephemeral resource testing, echoprovider, multi-step patterns


Test Lifecycle

The framework runs each TestStep through: plan → apply → refresh → final plan. If the final plan shows a diff, the test fails (unless ExpectNonEmptyPlan is set). After all steps, destroy runs followed by CheckDestroy. This means every test automatically verifies that configurations apply cleanly and produce no drift — no assertions needed for that.


Test Function Structure

func TestAccExample_basic(t *testing.T) {
    var widget example.Widget
    rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)
    resourceName := "example_widget.test"

    resource.ParallelTest(t, resource.TestCase{
        PreCheck:                 func() { testAccPreCheck(t) },
        ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
        CheckDestroy:             testAccCheckExampleDestroy,
        Steps: []resource.TestStep{
            {
                Config: testAccExampleConfig_basic(rName),
                ConfigStateChecks: []statecheck.StateCheck{
                    stateCheckExampleExists(resourceName, &widget),
                    statecheck.ExpectKnownValue(resourceName,
                        tfjsonpath.New("name"), knownvalue.StringExact(rName)),
                    statecheck.ExpectKnownValue(resourceName,
                        tfjsonpath.New("id"), knownvalue.NotNull()),
                },
            },
        },
    })
}

Use resource.ParallelTest by default. Use resource.Test only when tests share state or cannot run concurrently.


Provider Factory

// provider_test.go — Plugin Framework with Protocol 6 (use Protocol5 variant if needed)
var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
    "example": providerserver.NewProtocol6WithError(New("test")()),
}

TestCase Fields

FieldPurpose
PreCheckfunc() — verify prerequisites (env vars, API access)
ProtoV6ProviderFactoriesPlugin Framework provider factories
CheckDestroyTestCheckFunc — verify resources destroyed after all steps
Steps[]TestStep — sequential test operations
TerraformVersionChecks[]tfversion.TerraformVersionCheck — gate by CLI version

TestStep Fields

Config Mode
FieldPurpose
ConfigInline HCL string to apply
ConfigStateChecks[]statecheck.StateCheck — modern assertions (preferred)
ConfigPlanChecksresource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{...}}
ExpectError*regexp.Regexp — expect failure matching pattern
ExpectNonEmptyPlanbool — expect non-empty plan after apply
PlanOnlybool — plan without applying
Destroybool — run destroy step
PreConfigfunc() — setup before step
Import Mode
FieldPurpose
ImportStatetrue to enable import mode
ImportStateVerifyVerify imported state matches prior state
ImportStateVerifyIgnore[]string — attributes to skip during verify
ImportStateKindresource.ImportBlockWithID — import block generation
ResourceNameResource address to import
ImportStateIdOverride the ID used for import

Check Functions

Modern: ConfigStateChecks (preferred)

Type-safe with aggregated error reporting. Compose built-in checks with custom statecheck.StateCheck implementations. See references/checks.md for full knownvalue types, tfjsonpath navigation, and comparers.

ConfigStateChecks: []statecheck.StateCheck{
    stateCheckExampleExists(resourceName, &widget),
    statecheck.ExpectKnownValue(resourceName,
        tfjsonpath.New("name"), knownvalue.StringExact("my-widget")),
    statecheck.ExpectKnownValue(resourceName,
        tfjsonpath.New("enabled"), knownvalue.Bool(true)),
    statecheck.ExpectKnownValue(resourceName,
        tfjsonpath.New("id"), knownvalue.NotNull()),
    statecheck.ExpectSensitiveValue(resourceName,
        tfjsonpath.New("api_key")),
},

Do not mix Check (legacy) and ConfigStateChecks in the same step.

Legacy: Check (for CheckDestroy and migration)

CheckDestroy on TestCase requires TestCheckFunc. The Check field on TestStep also accepts TestCheckFunc but prefer ConfigStateChecks for new tests.

Check: resource.ComposeAggregateTestCheckFunc(
    resource.TestCheckResourceAttr(name, "key", "expected"),
    resource.TestCheckResourceAttrSet(name, "id"),
    resource.TestCheckNoResourceAttr(name, "removed"),
    resource.TestMatchResourceAttr(name, "url", regexp.MustCompile(`^https://`)),
    resource.TestCheckResourceAttrPair(res1, "ref_id", res2, "id"),
),

ComposeAggregateTestCheckFunc reports all errors; ComposeTestCheckFunc fails fast on the first.


Config Helpers

Use numbered format verbs — %[1]q for quoted strings, %[1]s for raw:

func testAccExampleConfig_basic(rName string) string {
    return fmt.Sprintf(`
resource "example_widget" "test" {
  name = %[1]q
}
`, rName)
}

func testAccExampleConfig_full(rName, description string) string {
    return fmt.Sprintf(`
resource "example_widget" "test" {
  name        = %[1]q
  description = %[2]q
  enabled     = true
}
`, rName, description)
}

Scenario Patterns

Basic + Update (combine in one test — updates are supersets of basic)
Steps: []resource.TestStep{
    {
        Config: testAccExampleConfig_basic(rName),
        ConfigStateChecks: []statecheck.StateCheck{
            stateCheckExampleExists(resourceName, &widget),
            statecheck.ExpectKnownValue(resourceName,
                tfjsonpath.New("name"), knownvalue.StringExact(rName)),
        },
    },
    {
        Config: testAccExampleConfig_full(rName, "updated"),
        ConfigStateChecks: []statecheck.StateCheck{
            stateCheckExampleExists(resourceName, &widget),
            statecheck.ExpectKnownValue(resourceName,
                tfjsonpath.New("description"), knownvalue.StringExact("updated")),
        },
    },
},
Import

After a config step, verify import produces identical state. Use ImportStateKind for import block generation:

{
    ResourceName:      resourceName,
    ImportState:       true,
    ImportStateVerify: true,
    ImportStateKind:   resource.ImportBlockWithID,
},
Disappears (resource deleted externally)
{
    Config: testAccExampleConfig_basic(rName),
    ConfigStateChecks: []statecheck.StateCheck{
        stateCheckExampleExists(resourceName, &widget),
        stateCheckExampleDisappears(resourceName),
    },
    ExpectNonEmptyPlan: true,
},
Validation (expect error)
{
    Config:      testAccExampleConfig_invalidName(""),
    ExpectError: regexp.MustCompile(`name must not be empty`),
},
Regression (two-commit workflow)

A proper bug fix uses at least two commits: first commit the regression test (which fails, confirming the bug), then commit the fix (test passes). This lets reviewers independently verify the test reproduces the issue by checking out the first commit, then advancing to the fix.

Name and document regression tests to identify the issue they fix. Include a link to the original bug report when possible.

// TestAccExample_regressionGH1234 verifies fix for https://github.com/org/repo/issues/1234
func TestAccExample_regressionGH1234(t *testing.T) {
    rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)
    resourceName := "example_widget.test"

    resource.ParallelTest(t, resource.TestCase{
        PreCheck:                 func() { testAccPreCheck(t) },
        ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
        CheckDestroy:             testAccCheckExampleDestroy,
        Steps: []resource.TestStep{
            {
                // Reproduce the issue: this config triggered the bug
                Config: testAccExampleConfig_regressionGH1234(rName),
                ConfigStateChecks: []statecheck.StateCheck{
                    stateCheckExampleExists(resourceName, nil),
                    statecheck.ExpectKnownValue(resourceName,
                        tfjsonpath.New("computed_field"), knownvalue.NotNull()),
                },
            },
        },
    })
}

Helper Functions

Custom StateCheck: Exists

Implement statecheck.StateCheck for API existence verification. Separate the exists check into its own function for reuse across steps — the source recommends this as a design principle:

type exampleExistsCheck struct {
    resourceAddress string
    widget          *example.Widget
}

func (e exampleExistsCheck) CheckState(ctx context.Context, req statecheck.CheckStateRequest, resp *statecheck.CheckStateResponse) {
    r, err := stateResourceAtAddress(req.State, e.resourceAddress)
    if err != nil {
        resp.Error = err
        return
    }

    id, ok := r.AttributeValues["id"].(string)
    if !ok {
        resp.Error = fmt.Errorf("no id found for %s", e.resourceAddress)
        return
    }

    conn := testAccAPIClient()
    widget, err := conn.GetWidget(id)
    if err != nil {
        resp.Error = fmt.Errorf("%s not found via API: %w", e.resourceAddress, err)
        return
    }

    if e.widget != nil {
        *e.widget = *widget
    }
}

func stateCheckExampleExists(name string, widget *example.Widget) statecheck.StateCheck {
    return exampleExistsCheck{resourceAddress: name, widget: widget}
}
Custom StateCheck: Disappears

Delete a resource via API to simulate external deletion:

type exampleDisappearsCheck struct {
    resourceAddress string
}

func (e exampleDisappearsCheck) CheckState(ctx context.Context, req statecheck.CheckStateRequest, resp *statecheck.CheckStateResponse) {
    r, err := stateResourceAtAddress(req.State, e.resourceAddress)
    if err != nil {
        resp.Error = err
        return
    }

    id := r.AttributeValues["id"].(string)
    conn := testAccAPIClient()
    resp.Error = conn.DeleteWidget(id)
}

func stateCheckExampleDisappears(name string) statecheck.StateCheck {
    return exampleDisappearsCheck{resourceAddress: name}
}
State Resource Lookup (shared utility)
func stateResourceAtAddress(state *tfjson.State, address string) (*tfjson.StateResource, error) {
    if state == nil || state.Values == nil || state.Values.RootModule == nil {
        return nil, fmt.Errorf("no state available")
    }
    for _, r := range state.Values.RootModule.Resources {
        if r.Address == address {
            return r, nil
        }
    }
    return nil, fmt.Errorf("not found in state: %s", address)
}
Destroy Check (TestCheckFunc — required by CheckDestroy)
func testAccCheckExampleDestroy(s *terraform.State) error {
    conn := testAccAPIClient()
    for _, rs := range s.RootModule().Resources {
        if rs.Type != "example_widget" {
            continue
        }
        _, err := conn.GetWidget(rs.Primary.ID)
        if err == nil {
            return fmt.Errorf("widget %s still exists", rs.Primary.ID)
        }
        if !isNotFoundError(err) {
            return err
        }
    }
    return nil
}
PreCheck
func testAccPreCheck(t *testing.T) {
    t.Helper()
    if os.Getenv("EXAMPLE_API_KEY") == "" {
        t.Fatal("EXAMPLE_API_KEY must be set for acceptance tests")
    }
}

Files

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs an API key

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
HashiCorp
Publisher
HashiCorp (Official)
Source
hashicorp/agent-skills · plugins/terraform/skills/provider-test-patterns · commit 5e30fa5
Licence
Mozilla Public License 2.0 (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by HashiCorp or HashiCorp.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download provider-test-patterns.zip