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, comparersreferences/sweepers.md— configuration des sweepers, TestMain, dépendancesreferences/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")
}
}