provider-test-patterns について
terraform-plugin-testing と Plugin Framework を使った Terraform プロバイダーの受け入れテストパターンを解説するガイドです。テストの流れ、状態チェック、インポートや更新、外部削除のシナリオを扱います。
主な機能
- 01受け入れテストのライフサイクル説明
- 02Plugin Framework のプロバイダーファクトリー設定
- 03ConfigStateChecks によるアサーション
- 04インポート、更新、検証のパターン
- 05破棄とリソース消失のチェック
活用シーン
- 01プロバイダー受け入れテストの構成
- 02apply 後のリソース状態の検証
- 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詳細
プロバイダーの受け入れテストパターン
Plugin Frameworkで terraform-plugin-testing を使用して受け入れテストを作成するためのパターンです。
ソース:HashiCorp Testing Patterns
参考資料(必要に応じて読み込んでください):
references/checks.md— statecheck、plancheck、knownvalue の型、tfjsonpath、比較関数references/sweepers.md— sweeper のセットアップ、TestMain、依存関係references/ephemeral.md— エフェメラルリソースのテスト、echoprovider、複数ステップのパターン
テストのライフサイクル
フレームワークは各 TestStep を plan → apply → refresh → final 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_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 のプロバイダーファクトリー |
CheckDestroy | TestCheckFunc — すべてのステップの後にリソースが破棄されたことを確認します |
Steps | []TestStep — 順次実行するテスト操作 |
TerraformVersionChecks | []tfversion.TerraformVersionCheck — CLI バージョンによる実行条件 |
TestStep フィールド
Config モード
| フィールド | 目的 |
|---|---|
Config | 適用するインライン HCL 文字列 |
ConfigStateChecks | []statecheck.StateCheck — 最新のアサーション(推奨) |
ConfigPlanChecks | resource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{...}} |
ExpectError | *regexp.Regexp — パターンに一致する失敗を想定します |
ExpectNonEmptyPlan | bool — 適用後に空でないプランを想定します |
PlanOnly | bool — 適用せずにプランを作成します |
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 は最初のエラーで失敗します。
Config ヘルパー
番号付きフォーマット指定子を使用してください。引用符付き文字列には %[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)
}
シナリオのパターン
基本 + 更新(更新は基本の上位互換なので、1つのテストにまとめます)
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:存在確認
API での存在確認を行う statecheck.StateCheck を実装します。手順間で再利用できるよう、存在確認を独自の関数に分けてください。これはソースで設計原則として推奨されています。
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:消失確認
外部からの削除をシミュレートするため、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)
}
Destroy チェック(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")
}
}