provider-test-patterns

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Provider Acceptance Test Patterns

Provider验收测试模式

Patterns for writing acceptance tests using terraform-plugin-testing with the Plugin Framework.
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

基于Plugin Framework,使用terraform-plugin-testing编写验收测试的模式。
参考文档(按需查阅):
  • references/checks.md
    — statecheck、plancheck、knownvalue类型、tfjsonpath、比较器
  • references/sweepers.md
    — 清理器设置、TestMain、依赖项
  • references/ephemeral.md
    — 临时资源测试、echoprovider、多步骤模式

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.

框架会按以下流程执行每个TestStep:plan(计划)→ apply(应用)→ refresh(刷新)→ final plan(最终计划)。如果最终计划显示差异,测试将失败(除非设置了
ExpectNonEmptyPlan
)。所有步骤完成后,会执行destroy(销毁)操作,随后运行
CheckDestroy
。这意味着每个测试都会自动验证配置是否能正常应用且无漂移——无需额外编写断言。

Test Function Structure

测试函数结构

go
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.

go
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()),
                },
            },
        },
    })
}
默认使用
resource.ParallelTest
。仅当测试需要共享状态或无法并发运行时,才使用
resource.Test

Provider Factory

Provider工厂

go
// 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")()),
}

go
// provider_test.go — 基于Protocol 6的Plugin Framework(按需使用Protocol5变体)
var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
    "example": providerserver.NewProtocol6WithError(New("test")()),
}

TestCase Fields

TestCase字段

FieldPurpose
PreCheck
func()
— verify prerequisites (env vars, API access)
ProtoV6ProviderFactories
Plugin Framework provider factories
CheckDestroy
TestCheckFunc
— verify resources destroyed after all steps
Steps
[]TestStep
— sequential test operations
TerraformVersionChecks
[]tfversion.TerraformVersionCheck
— gate by CLI version

字段用途
PreCheck
func()
— 验证前置条件(环境变量、API访问权限等)
ProtoV6ProviderFactories
Plugin Framework的Provider工厂
CheckDestroy
TestCheckFunc
— 验证所有步骤完成后资源已销毁
Steps
[]TestStep
— 按顺序执行的测试操作
TerraformVersionChecks
[]tfversion.TerraformVersionCheck
— 根据CLI版本限制测试运行

TestStep Fields

TestStep字段

Config Mode

配置模式

FieldPurpose
Config
Inline HCL string to apply
ConfigStateChecks
[]statecheck.StateCheck
— modern assertions (preferred)
ConfigPlanChecks
resource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{...}}
ExpectError
*regexp.Regexp
— expect failure matching pattern
ExpectNonEmptyPlan
bool
— expect non-empty plan after apply
PlanOnly
bool
— plan without applying
Destroy
bool
— run destroy step
PreConfig
func()
— setup before step
字段用途
Config
要应用的内联HCL字符串
ConfigStateChecks
[]statecheck.StateCheck
— 现代断言方式(推荐使用)
ConfigPlanChecks
resource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{...}}
— 计划阶段检查
ExpectError
*regexp.Regexp
— 预期测试失败并匹配指定正则表达式
ExpectNonEmptyPlan
bool
— 预期应用后仍存在非空计划
PlanOnly
bool
— 仅执行计划操作,不应用配置
Destroy
bool
— 执行销毁步骤
PreConfig
func()
— 步骤执行前的前置操作

Import Mode

导入模式

FieldPurpose
ImportState
true
to enable import mode
ImportStateVerify
Verify imported state matches prior state
ImportStateVerifyIgnore
[]string
— attributes to skip during verify
ImportStateKind
resource.ImportBlockWithID
— import block generation
ResourceName
Resource address to import
ImportStateId
Override the ID used for import

字段用途
ImportState
设置为
true
以启用导入模式
ImportStateVerify
验证导入后的状态与之前的状态一致
ImportStateVerifyIgnore
[]string
— 验证时跳过的属性列表
ImportStateKind
resource.ImportBlockWithID
— 生成导入块的类型
ResourceName
要导入的资源地址
ImportStateId
覆盖导入时使用的ID

Check Functions

检查函数

Modern: ConfigStateChecks (preferred)

现代方式:ConfigStateChecks(推荐)

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.
go
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.
类型安全且支持聚合错误报告。可将内置检查与自定义
statecheck.StateCheck
实现组合使用。完整的knownvalue类型、tfjsonpath导航和比较器请查阅
references/checks.md
go
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")),
},
不要在同一个步骤中混合使用
Check
(旧版)和
ConfigStateChecks

Legacy: Check (for CheckDestroy and migration)

旧版:Check(用于CheckDestroy和迁移场景)

CheckDestroy
on
TestCase
requires
TestCheckFunc
. The
Check
field on
TestStep
also accepts
TestCheckFunc
but prefer
ConfigStateChecks
for new tests.
go
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.

TestCase
中的
CheckDestroy
需要使用
TestCheckFunc
TestStep
Check
字段也接受
TestCheckFunc
,但新测试推荐使用
ConfigStateChecks
go
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
会报告所有错误;
ComposeTestCheckFunc
会在第一个错误出现时立即终止。

Config Helpers

配置助手

Use numbered format verbs —
%[1]q
for quoted strings,
%[1]s
for raw:
go
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)
}

使用带编号的格式化动词 —
%[1]q
用于带引号的字符串,
%[1]s
用于原始字符串:
go
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)

基础+更新(合并到一个测试中 — 更新场景是基础场景的超集)

go
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")),
        },
    },
},
go
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:
go
{
    ResourceName:      resourceName,
    ImportState:       true,
    ImportStateVerify: true,
    ImportStateKind:   resource.ImportBlockWithID,
},
在配置步骤完成后,验证导入操作是否能生成一致的状态。使用
ImportStateKind
生成导入块:
go
{
    ResourceName:      resourceName,
    ImportState:       true,
    ImportStateVerify: true,
    ImportStateKind:   resource.ImportBlockWithID,
},

Disappears (resource deleted externally)

资源消失(资源被外部删除)

go
{
    Config: testAccExampleConfig_basic(rName),
    ConfigStateChecks: []statecheck.StateCheck{
        stateCheckExampleExists(resourceName, &widget),
        stateCheckExampleDisappears(resourceName),
    },
    ExpectNonEmptyPlan: true,
},
go
{
    Config: testAccExampleConfig_basic(rName),
    ConfigStateChecks: []statecheck.StateCheck{
        stateCheckExampleExists(resourceName, &widget),
        stateCheckExampleDisappears(resourceName),
    },
    ExpectNonEmptyPlan: true,
},

Validation (expect error)

验证测试(预期错误)

go
{
    Config:      testAccExampleConfig_invalidName(""),
    ExpectError: regexp.MustCompile(`name must not be empty`),
},
go
{
    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.
go
// 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()),
                },
            },
        },
    })
}

正确的Bug修复至少需要两次提交:第一次提交回归测试(测试失败,确认Bug存在),然后提交修复代码(测试通过)。这样评审人员可以通过检出第一次提交独立验证测试是否能复现问题,再切换到修复版本确认问题已解决。
回归测试的命名和文档应明确说明其修复的问题。尽可能包含原始Bug报告的链接。
go
// TestAccExample_regressionGH1234 验证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{
            {
                // 复现问题:此配置会触发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

自定义StateCheck:资源存在验证

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:
go
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}
}
实现
statecheck.StateCheck
用于通过API验证资源是否存在。将存在性检查拆分为独立函数以便在多个步骤中复用——这是官方推荐的设计原则:
go
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

自定义StateCheck:资源消失验证

Delete a resource via API to simulate external deletion:
go
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}
}
通过API删除资源以模拟外部删除场景:
go
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)

状态资源查找(共享工具函数)

go
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)
}
go
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)

销毁检查(TestCheckFunc — CheckDestroy必填)

go
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
}
go
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

前置检查

go
func testAccPreCheck(t *testing.T) {
    t.Helper()
    if os.Getenv("EXAMPLE_API_KEY") == "" {
        t.Fatal("EXAMPLE_API_KEY must be set for acceptance tests")
    }
}
go
func testAccPreCheck(t *testing.T) {
    t.Helper()
    if os.Getenv("EXAMPLE_API_KEY") == "" {
        t.Fatal("EXAMPLE_API_KEY must be set for acceptance tests")
    }
}