provider-test-patterns

Par hashicorp · agent-skills

Patterns de tests d'acceptation pour un provider Terraform utilisant terraform-plugin-testing avec le Plugin Framework. Couvre la structure des tests, les champs TestCase/TestStep, les ConfigStateChecks avec des implémentations personnalisées de `statecheck.StateCheck`, les plan checks, `CompareValue` pour les assertions inter-étapes, les helpers de configuration, les tests d'import avec `ImportStateKind`, les sweepers, et les patterns de scénarios (basic, update, disappears, validation, regression), ainsi que les tests de ressources éphémères avec le package `echoprovider`. À utiliser lors de l'écriture, la revue ou le débogage de tests d'acceptation de provider, y compris les questions sur `statecheck`, `plancheck`, `TestCheckFunc`, `CheckDestroy`, `ExpectError`, la vérification de l'état à l'import, les ressources éphémères, ou la structure des fichiers de test.

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

Modèles de tests d'acceptation pour les fournisseurs

Modèles pour l'écriture de tests d'acceptation en utilisant terraform-plugin-testing avec le Plugin Framework.

Source : HashiCorp Testing Patterns

Références (à charger selon les besoins) :

  • references/checks.md — statecheck, plancheck, types knownvalue, tfjsonpath, comparers
  • references/sweepers.md — configuration des sweepers, TestMain, dépendances
  • references/ephemeral.md — tests de ressources éphémères, echoprovider, modèles multi-étapes

Cycle de vie des tests

Le framework exécute chaque TestStep selon : plan → apply → refresh → plan final. Si le plan final affiche un diff, le test échoue (sauf si ExpectNonEmptyPlan est défini). Après toutes les étapes, destroy s'exécute suivi de CheckDestroy. Cela signifie que chaque test vérifie automatiquement que les configurations s'appliquent correctement et ne produisent aucune dérive — aucune assertion n'est nécessaire pour cela.


Structure de la fonction de test

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

Utilisez resource.ParallelTest par défaut. N'utilisez resource.Test que si les tests partagent l'état ou ne peuvent pas s'exécuter en parallèle.


Fabrique de fournisseur

// provider_test.go — Plugin Framework avec Protocol 6 (utilisez la variante Protocol5 si nécessaire)
var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
    "example": providerserver.NewProtocol6WithError(New("test")()),
}

Champs de TestCase

Champ Objectif
PreCheck func() — vérifier les prérequis (variables d'env, accès API)
ProtoV6ProviderFactories Fabriques de fournisseur du Plugin Framework
CheckDestroy TestCheckFunc — vérifier que les ressources sont détruites après toutes les étapes
Steps []TestStep — opérations de test séquentielles
TerraformVersionChecks []tfversion.TerraformVersionCheck — filtrer par version CLI

Champs de TestStep

Mode Config

Champ Objectif
Config Chaîne HCL inline à appliquer
ConfigStateChecks []statecheck.StateCheck — assertions modernes (préférées)
ConfigPlanChecks resource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{...}}
ExpectError *regexp.Regexp — attendre un échec correspondant au motif
ExpectNonEmptyPlan bool — attendre un plan non vide après apply
PlanOnly bool — plan sans application
Destroy bool — exécuter une étape de destruction
PreConfig func() — setup avant l'étape

Mode Import

Champ Objectif
ImportState true pour activer le mode import
ImportStateVerify Vérifier que l'état importé correspond à l'état précédent
ImportStateVerifyIgnore []string — attributs à ignorer lors de la vérification
ImportStateKind resource.ImportBlockWithID — génération de bloc import
ResourceName Adresse de ressource à importer
ImportStateId Remplacer l'ID utilisé pour l'import

Fonctions de vérification

Moderne : ConfigStateChecks (préféré)

Type-safe avec rapport d'erreurs agrégé. Composez les vérifications intégrées avec des implémentations personnalisées de statecheck.StateCheck. Consultez references/checks.md pour tous les types knownvalue, la navigation tfjsonpath et les 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")),
},

Ne mélangez pas Check (hérité) et ConfigStateChecks dans la même étape.

Hérité : Check (pour CheckDestroy et migration)

CheckDestroy sur TestCase requiert TestCheckFunc. Le champ Check sur TestStep accepte aussi TestCheckFunc mais préférez ConfigStateChecks pour les nouveaux 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 rapporte toutes les erreurs ; ComposeTestCheckFunc s'arrête au premier.


Assistants de configuration

Utilisez les verbes de format numérotés — %[1]q pour les chaînes entre guillemets, %[1]s pour le brut :

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

Modèles de scénarios

Basic + Update (combiner dans un seul test — les updates sont des sur-ensembles du 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

Après une étape de config, vérifiez que l'import produit un état identique. Utilisez ImportStateKind pour la génération de bloc import :

{
    ResourceName:      resourceName,
    ImportState:       true,
    ImportStateVerify: true,
    ImportStateKind:   resource.ImportBlockWithID,
},

Disappears (ressource supprimée en externe)

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

Validation (attendre une erreur)

{
    Config:      testAccExampleConfig_invalidName(""),
    ExpectError: regexp.MustCompile(`name must not be empty`),
},

Regression (workflow deux commits)

Une correction de bug appropriée utilise au moins deux commits : commiter d'abord le test de régression (qui échoue, confirmant le bug), puis commiter le correctif (le test passe). Cela permet aux relecteurs de vérifier indépendamment que le test reproduit le problème en vérifiant le premier commit, puis en passant au correctif.

Nommez et documentez les tests de régression pour identifier le problème qu'ils corrigent. Incluez un lien vers le rapport de bug d'origine si possible.

// TestAccExample_regressionGH1234 vérifie le correctif pour 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{
            {
                // Reproduire le problème : cette config a déclenché le bug
                Config: testAccExampleConfig_regressionGH1234(rName),
                ConfigStateChecks: []statecheck.StateCheck{
                    stateCheckExampleExists(resourceName, nil),
                    statecheck.ExpectKnownValue(resourceName,
                        tfjsonpath.New("computed_field"), knownvalue.NotNull()),
                },
            },
        },
    })
}

Fonctions auxiliaires

StateCheck personnalisé : Exists

Implémentez statecheck.StateCheck pour la vérification d'existence d'API. Séparez la vérification d'existence dans sa propre fonction pour la réutilisation entre les étapes — la source recommande cela comme principe de conception :

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

Supprimez une ressource via API pour simuler une suppression externe :

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

Lookup de ressource d'état (utilitaire partagé)

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

Vérification Destroy (TestCheckFunc — requise par 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")
    }
}

Skills similaires