关于 provider-test-patterns
这份模式指南介绍如何使用 terraform-plugin-testing 和 Plugin Framework 编写 Terraform Provider 验收测试,涵盖测试生命周期、状态检查及导入、更新和外部删除等场景。
主要功能
- 01验收测试生命周期说明
- 02Plugin Framework Provider 工厂配置
- 03ConfigStateChecks 断言
- 04导入、更新和验证模式
- 05销毁与资源消失检查
适用场景
- 01组织 Provider 验收测试
- 02验证 apply 后的资源状态
- 03测试导入和外部删除场景
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ hashicorp/ agent-skills --skill provider-test-patterns安装后在 Claude Code 中输入/provider-test-patterns确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/checks.mdephemeral.mdsweepers.md详细介绍
Provider 验收测试模式
使用 terraform-plugin-testing 和 Plugin Framework 编写验收测试的模式。
参考资料(按需加载):
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 字段
| 字段 | 用途 |
|---|---|
PreCheck | func() — 验证前提条件(环境变量、API 访问权限) |
ProtoV6ProviderFactories | Plugin Framework Provider 工厂 |
CheckDestroy | TestCheckFunc — 验证所有步骤完成后资源已销毁 |
Steps | []TestStep — 按顺序执行的测试操作 |
TerraformVersionChecks | []tfversion.TerraformVersionCheck — 根据 CLI 版本限制测试 |
TestStep 字段
配置模式
| 字段 | 用途 |
|---|---|
Config | 要应用的内联 HCL 字符串 |
ConfigStateChecks | []statecheck.StateCheck — 现代断言(首选) |
ConfigPlanChecks | resource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{...}} |
ExpectError | *regexp.Regexp — 预期出现与模式匹配的错误 |
ExpectNonEmptyPlan | bool — 预期应用后存在非空 plan |
PlanOnly | bool — 只执行 plan,不进行 apply |
Destroy | bool — 执行 destroy 步骤 |
PreConfig | func() — 在步骤执行前进行设置 |
导入模式
| 字段 | 用途 |
|---|---|
ImportState | 设为 true 以启用导入模式 |
ImportStateVerify | 验证导入的状态是否与先前状态一致 |
ImportStateVerifyIgnore | []string — 验证时跳过的属性 |
ImportStateKind | resource.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")
}
}