mcpField
P
provider-test-patterns

provider-test-patterns

github.com/hashicorp测试与质量9093.3k 次安装今天更新
代码仓库

关于 provider-test-patterns

这份模式指南介绍如何使用 terraform-plugin-testing 和 Plugin Framework 编写 Terraform Provider 验收测试,涵盖测试生命周期、状态检查及导入、更新和外部删除等场景。

主要功能

  1. 01验收测试生命周期说明
  2. 02Plugin Framework Provider 工厂配置
  3. 03ConfigStateChecks 断言
  4. 04导入、更新和验证模式
  5. 05销毁与资源消失检查

适用场景

  1. 01组织 Provider 验收测试
  2. 02验证 apply 后的资源状态
  3. 03测试导入和外部删除场景

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/hashicorp/agent-skills --skill provider-test-patterns

安装后在 Claude Code 中输入/provider-test-patterns确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
checks.md
ephemeral.md
sweepers.md

详细介绍

Provider 验收测试模式

使用 terraform-plugin-testing 和 Plugin Framework 编写验收测试的模式。

来源:HashiCorp Testing Patterns

参考资料(按需加载):

  • references/checks.md — statecheck、plancheck、knownvalue 类型、tfjsonpath、比较器
  • references/sweepers.md — sweeper 设置、TestMain、依赖项
  • references/ephemeral.md — ephemeral 资源测试、echoprovider、多步骤模式

测试生命周期

框架会依次对每个 TestStep 执行:plan → apply → refresh → final plan。如果最终 plan 显示有差异,测试就会失败(除非设置了 ExpectNonEmptyPlan)。所有步骤完成后会执行 destroy,然后执行 CheckDestroy。这意味着每项测试都会自动验证配置是否成功应用且没有产生漂移——无需为此编写断言。


测试函数结构

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 工厂

// 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 字段

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

TestStep 字段

配置模式
字段用途
Config要应用的内联 HCL 字符串
ConfigStateChecks[]statecheck.StateCheck — 现代断言(首选)
ConfigPlanChecksresource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{...}}
ExpectError*regexp.Regexp — 预期出现与模式匹配的错误
ExpectNonEmptyPlanbool — 预期应用后存在非空 plan
PlanOnlybool — 只执行 plan,不进行 apply
Destroybool — 执行 destroy 步骤
PreConfigfunc() — 在步骤执行前进行设置
导入模式
字段用途
ImportState设为 true 以启用导入模式
ImportStateVerify验证导入的状态是否与先前状态一致
ImportStateVerifyIgnore[]string — 验证时跳过的属性
ImportStateKindresource.ImportBlockWithID — 导入块生成方式
ResourceName要导入的资源地址
ImportStateId覆盖用于导入的 ID

检查函数

现代方式:ConfigStateChecks(首选)

具备类型安全性,并能汇总报告错误。可将内置检查与自定义 statecheck.StateCheck 实现组合使用。有关完整的 knownvalue 类型、tfjsonpath 导航和比较器,请参阅 references/checks.md。

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。

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

TestCase 上的 CheckDestroy 要求使用 TestCheckFunc。TestStep 上的 Check 字段也接受 TestCheckFunc,但编写新测试时请优先使用 ConfigStateChecks。

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 在遇到第一个错误时就会失败。


配置辅助函数

使用带编号的格式动词——带引号的字符串使用 %[1]q,原始字符串使用 %[1]s:

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

场景模式

基础 + 更新(合并到一个测试中——更新涵盖基础场景的全部内容)
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")),
        },
    },
},
导入

完成配置步骤后,验证导入是否生成相同的状态。使用 ImportStateKind 生成导入块:

{
    ResourceName:      resourceName,
    ImportState:       true,
    ImportStateVerify: true,
    ImportStateKind:   resource.ImportBlockWithID,
},
资源消失(资源在外部被删除)
{
    Config: testAccExampleConfig_basic(rName),
    ConfigStateChecks: []statecheck.StateCheck{
        stateCheckExampleExists(resourceName, &widget),
        stateCheckExampleDisappears(resourceName),
    },
    ExpectNonEmptyPlan: true,
},
验证(预期出现错误)
{
    Config:      testAccExampleConfig_invalidName(""),
    ExpectError: regexp.MustCompile(`name must not be empty`),
},
回归测试(两次提交工作流)

正确的缺陷修复至少需要两次提交:先提交回归测试(测试失败,以确认缺陷存在),再提交修复(测试通过)。这样,评审者可以先检出第一次提交,独立验证测试能复现问题,然后再切换到修复提交。

为回归测试命名并编写文档,明确指出它们修复的问题。尽可能附上原始缺陷报告的链接。

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

辅助函数

自定义 StateCheck:Exists

实现 statecheck.StateCheck,用于验证 API 中是否存在资源。将存在性检查单独封装为一个函数,以便在多个步骤中复用——源代码建议将其作为一项设计原则:

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:Disappears

通过 API 删除资源,以模拟外部删除:

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 资源查找(共享工具函数)
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)
}
销毁检查(TestCheckFunc——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")
    }
}

评论

支持 Markdown