
Depuis la 1.6, Terraform embarque son propre framework de tests : des
fichiers .tftest.hcl décrivent des scénarios, Terraform les applique et
vérifie vos assertions. C'est le seul moyen de prouver qu'un module fait ce
qu'il annonce, valeurs par défaut comprises, et surtout qu'il refuse ce
qu'il doit refuser.
Tout ce qui suit a été exécuté sur Terraform v1.15.4 : sorties, messages
d'erreur, événements JSON et codes de retour compris. Deux idées répandues n'y
survivent pas, celle du projet enveloppe obligatoire autour du module testé, et
celle du terraform test qui ne créerait rien.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Où placer une suite pour tester un module, sans configuration autour
- Comment s'écrit un
run, avec sesassertet sesvariables - Prouver un refus avec
expect_failures, ce qu'aucune assertion ne fait - Enchaîner des runs, et préparer le terrain avec un module de setup
- Automatiser : code de retour, flux JSON, et le piège du
-filter - Tester sans créer avec
command = planet le mocking
Prérequis
Section intitulée « Prérequis »- Terraform 1.6 ou plus récent pour
terraform test, 1.7 pour le mocking (installer Terraform). - Savoir écrire l'interface d'un module,
variables,
validationet outputs compris.
Où vit une suite de tests
Section intitulée « Où vit une suite de tests »terraform test s'exécute contre la configuration du répertoire courant. Or le
répertoire d'un module est une configuration : posez-y un dossier tests/, et
vous testez le module lui-même, sans projet enveloppe.
etiquette/├── main.tf├── variables.tf├── outputs.tf└── tests/ └── etiquette.tftest.hclcd etiquetteterraform testC'est la forme à privilégier pour un module publié : la suite voyage avec lui, et un consommateur peut la rejouer. Une configuration racine qui appelle le module reste utile pour tester une intégration, pas le module isolé.
L'anatomie d'un fichier de test
Section intitulée « L'anatomie d'un fichier de test »variables { prefixe = "atelier"}
run "defaut_sans_suffixe" { assert { condition = output.etiquette == "atelier" error_message = "Sans suffixe, l'etiquette doit valoir le prefixe seul." }
assert { condition = output.longueur == 7 error_message = "La longueur doit correspondre a l'etiquette produite." }}Trois niveaux à distinguer, et c'est le point compris de travers le plus souvent.
Le bloc variables de niveau fichier s'applique à tous les runs. Un bloc
variables dans un run l'emporte, pour ce run seulement. Et chaque run
porte autant d'assert que nécessaire, chacun avec son error_message, qui est
exactement ce que vous lirez en cas d'échec.
apply par défaut, et de la vraie infrastructure
Section intitulée « apply par défaut, et de la vraie infrastructure »Un run applique par défaut : « By default, each run block executes with
command = apply ». Ce n'est pas un détail, c'est une création réelle de
ressources, suivie d'une destruction :
tests/plaque.tftest.hcl... in progress run "creation"... passtests/plaque.tftest.hcl... tearing downtests/plaque.tftest.hcl... passAprès ce passage, le fichier créé par le module a bien disparu, mais le répertoire qui le contenait, lui, reste : le teardown défait ce que le run a créé, pas ses effets de bord. La documentation est explicite sur le risque : « This command creates real infrastructure and will attempt to clean up the testing infrastructure on completion. Monitor the output carefully to ensure this cleanup process is successful. »
Quand seul le plan vous intéresse, dites-le, et rien ne sera créé :
run "verifie_sans_creer" { command = plan
assert { condition = output.etiquette == "atelier" error_message = "L'etiquette calculee au plan doit deja etre correcte." }}Prouver un refus : expect_failures
Section intitulée « Prouver un refus : expect_failures »Un module sérieux refuse les entrées invalides. Ce comportement ne s'asserte pas, puisque rien ne doit être produit : il se déclare.
run "prefixe_trop_court_refuse" { command = plan
variables { prefixe = "ab" }
expect_failures = [var.prefixe]}Le run passe parce que la validation du module a rejeté la valeur, et pour
aucune autre raison. Retirez cette validation, et le même run échoue, avec un
message qui nomme l'objet :
Error: Missing expected failure
The checkable object, var.prefixe, was expected to report an error but didnot.C'est le seul mécanisme qui teste la garde d'un module, et il distingue une
suite décrivant un chemin heureux d'une suite décrivant un contrat. Une
réserve mesurée : expect_failures ne couvre que les objets de la configuration
testée, pas ceux d'un module enfant appelé par elle.
Enchaîner les runs, et préparer le terrain
Section intitulée « Enchaîner les runs, et préparer le terrain »Les run d'un fichier s'exécutent en séquence et partagent leur contexte. Un
run peut donc exécuter une autre configuration pour préparer une valeur :
run "prepare_le_prefixe" { module { source = "./prefixe" }}
run "utilise_le_prefixe_prepare" { variables { prefixe = run.prepare_le_prefixe.valeur }
assert { condition = output.etiquette == "ATELIER" error_message = "L'etiquette doit reprendre le prefixe prepare." }}Deux règles à retenir. Le bloc module d'un run n'accepte que source et
version, tout autre argument étant refusé sur An argument named "..." is not expected here. Et les sorties d'un run précédent se lisent par
run.<nom>.<sortie>, ce qui permet de chaîner un module de préparation et
le module testé.
Tester sans créer : le mocking
Section intitulée « Tester sans créer : le mocking »Depuis la 1.7, un fichier de test peut simuler un provider entier, ce qui
répond au besoin « tester sans infrastructure » bien mieux qu'un
command = plan :
mock_provider "local" {}
run "sans_provider_reel" { assert { condition = output.chemin == "./plaques/atelier.txt" error_message = "Le chemin doit etre calcule meme avec un provider simule." }}Vérifié en 1.15.4 : le run passe, en apply, et aucun fichier réel n'est
créé. Les blocs override_resource, override_data et override_module
permettent de descendre plus finement, jusqu'à figer les attributs d'une
ressource précise.
Automatiser : code de retour et flux JSON
Section intitulée « Automatiser : code de retour et flux JSON »C'est ici que se joue l'intégration continue, et c'est ce que la sortie humaine ne donne pas.
| Situation | Code de retour |
|---|---|
| tous les runs passent | 0 |
| un run échoue | 1 |
| aucun fichier de test trouvé | 0 |
Le flux JSONL est fait pour être consommé :
terraform test -json{"type":"test_summary","test_summary":{"status":"pass","passed":4,"failed":0,"errored":0,"skipped":0}}Les cinq types d'événements observés sont version, test_abstract,
test_file, test_run et test_summary. L'option -verbose ajoute
test_state pour les runs en apply et test_plan pour ceux en plan, ce qui
permet de vérifier qu'une suite emploie bien les deux commandes.
Lire un échec, et ce qui se passe ensuite
Section intitulée « Lire un échec, et ce qui se passe ensuite »Une assertion fausse affiche la condition, un diff et votre message :
run "defaut_sans_suffixe"... fail
Error: Test assertion failed
on tests/etiquette.tftest.hcl line 7, in run "defaut_sans_suffixe": 7: condition = output.etiquette == "PAS-CA" ├──────────────── │ Diff: │ --- actual │ +++ expected │ - "atelier" │ + "PAS-CA"
Sans suffixe, l'etiquette doit valoir le prefixe seul.Le sort de la suite du fichier dépend ensuite de la nature du problème, et la nuance est mesurable :
| Ce qui arrive | Les runs suivants |
|---|---|
| une assertion est fausse | s'exécutent quand même |
| une erreur survient (référence inconnue, plan refusé) | passent en skip |
run "defaut"... fail ← assertion fausse run "avec_suffixe"... pass
run "defaut"... fail ← Reference to undeclared output value run "avec_suffixe"... skipUn skipped non nul dans le résumé JSON est donc un signal, jamais un
détail : quelque chose a cassé plus tôt, et une partie de la suite n'a rien
vérifié.
Une suite se prouve en la faisant échouer
Section intitulée « Une suite se prouve en la faisant échouer »Voici la question qui décide de la valeur d'une suite : détecte-t-elle une régression ? Une suite de quatre runs sans la moindre assertion passe au vert et sort en 0. Le contrôle qui tranche est celui de tout test unitaire, la mutation :
-
Copier le module dans un répertoire temporaire.
-
Casser un seul comportement : retirer une
validation, changer une valeur par défaut, remplacer un séparateur. -
Rejouer la suite contre cette copie, et exiger un code de retour 1.
-
Recommencer pour chaque comportement que la suite prétend couvrir.
Une suite qui survit à une mutation ne teste pas ce qu'elle prétend tester. C'est un contrôle bon marché, et le seul qui distingue une suite utile d'une suite décorative.
init reste nécessaire, parfois
Section intitulée « init reste nécessaire, parfois »Sur un module autonome, sans provider ni module appelé, terraform test fonctionne
sans init. Dès que la configuration appelle un module ou déclare un
provider, il faut l'installer d'abord :
Error: Module not installed
This module is not yet installed. Run "terraform init" to install all modulesrequired by this configuration.Dépannage
Section intitulée « Dépannage »| Symptôme | Cause probable | Correction |
|---|---|---|
Success! 0 passed, 0 failed. | aucun fichier trouvé, ou -filter hors cible | vérifier le chemin, relatif au répertoire courant |
Error: Module not installed | init non joué sur une configuration qui appelle un module | terraform init |
Reference to undeclared output value | l'output asséré n'existe pas dans le module | corriger le nom, les runs suivants étaient en skip |
Missing expected failure | la garde attendue n'a pas rejeté la valeur | rétablir la validation, ou corriger l'objet listé |
An argument named "..." is not expected here | argument interdit dans le bloc module d'un run | ne garder que source et version |
| des ressources subsistent après un test | le teardown a échoué | lire la fin de la sortie, nettoyer à la main |
Terraform has no command named "test" | binaire antérieur à la 1.6 | mettre Terraform à jour |
Mettre en pratique
Section intitulée « Mettre en pratique »Le lab tester un module vous fait écrire la suite d'un module complet : son
défaut, ses deux options, et son refus d'une entrée invalide. La
validation ne lit pas votre suite, elle la mute : quatre comportements du
module sont cassés à tour de rôle dans une copie, et votre suite doit tomber à
chaque fois. Une suite de quatre runs sans assertion passe terraform test et
échoue sur cinq contrôles. Il se joue hors ligne.
À retenir
Section intitulée « À retenir »- Une suite posée dans
<module>/tests/teste le module lui-même, sans projet enveloppe. - Un
runapplique par défaut :terraform testcrée de la vraie infrastructure, puis la détruit. - Le bloc
variablesdu fichier s'applique à tous les runs, celui d'unrunl'emporte. expect_failuresest le seul moyen de prouver qu'un module refuse une entrée, et il ne couvre que la configuration testée.- Les runs s'enchaînent :
run.<nom>.<sortie>, avec un blocmodulelimité àsourceetversion. - Le mocking (1.7+) teste contre un provider simulé, sans rien créer.
- Le code de retour vaut 0 ou 1, et le flux
-jsonportetest_summary. - Un
-filterhors cible rend0 passedet un code 0 : une CI verte peut n'avoir rien testé. - Une assertion fausse laisse la suite continuer, une erreur met les runs
suivants en
skip. - Une suite ne vaut que ce qu'elle détecte : mutez le module pour le vérifier.
FAQ : questions fréquentes
Section intitulée « FAQ : questions fréquentes »Les questions ci-dessous portent sur ce qui casse en pratique : la suite qui passe sans rien tester, le refus qu'on ne sait pas prouver, et le test qui crée des ressources bien réelles.
Le module est déjà une configuration
etiquette/
├── main.tf
├── variables.tf
├── outputs.tf
└── tests/
└── etiquette.tftest.hcl
cd etiquette
terraform test
Vérifié sur Terraform 1.15.4 : la suite passe sans qu'aucun projet n'appelle le module. Les sorties se lisent directement par output.<nom>.C'est la forme à privilégier pour un module publié : la suite voyage avec lui. Une configuration racine qui appelle le module reste utile pour tester une intégration, pas le module isolé.apply est le défaut, pas plan
« By default, eachrun block executes with command = apply », et « This command creates real infrastructure and will attempt to clean up the testing infrastructure on completion. »tests/plaque.tftest.hcl... in progress
run "creation"... pass
tests/plaque.tftest.hcl... tearing down
tests/plaque.tftest.hcl... pass
Mesuré sur 1.15.4 : le fichier créé par le module a bien disparu après le teardown, mais le répertoire qui le contenait reste.Pour ne rien créer, deux options : command = plan dans le run, ou le mocking du provider.Déclarer l'échec attendu
run "prefixe_trop_court_refuse" {
command = plan
variables {
prefixe = "ab"
}
expect_failures = [var.prefixe]
}
Retirez la validation du module, et le même run échoue :Error: Missing expected failure
The checkable object, var.prefixe, was expected to report an error but did
not.
Une réserve mesurée sur 1.15.4 : expect_failures ne couvre que les objets de la configuration testée, pas ceux d'un module enfant appelé par elle.Le filtre qui ne teste rien
$ terraform test -filter=tests/inexistant.tftest.hcl
Success! 0 passed, 0 failed.
Code de retour 0, mesuré sur 1.15.4.Deux réflexes :- le chemin de
-filterest relatif au répertoire de travail, préfixetests/compris ; - une chaîne d'intégration sérieuse vérifie le nombre de runs exécutés, pas seulement le code de retour.
terraform test -json | jq -c 'select(.type=="test_summary").test_summary'
Le contrat machine
| Situation | Code de retour |
|---|---|
| tous les runs passent | 0 |
| un run échoue | 1 |
| aucun fichier de test trouvé | 0 |
terraform test -json
{"type":"test_summary","test_summary":{"status":"pass","passed":4,"failed":0,"errored":0,"skipped":0}}
Types d'événements observés : version, test_abstract, test_file, test_run et test_summary. L'option -verbose ajoute test_state pour les runs en apply et test_plan pour ceux en plan.Un module de préparation, puis le module testé
run "prepare_le_prefixe" {
module {
source = "./prefixe"
}
}
run "utilise_le_prefixe_prepare" {
variables {
prefixe = run.prepare_le_prefixe.valeur
}
assert {
condition = output.etiquette == "ATELIER"
error_message = "L'etiquette doit reprendre le prefixe prepare."
}
}
Mesuré sur 1.15.4 : tout autre argument dans le bloc module est refusé.Error: Unsupported argument
An argument named "providers" is not expected here.
Échec et erreur ne se comportent pas pareil
Mesuré sur 1.15.4 : run "defaut"... fail ← assertion fausse
run "avec_suffixe"... pass
run "defaut"... fail ← Reference to undeclared output value
run "avec_suffixe"... skip
| Ce qui arrive | Les runs suivants |
|---|---|
| une assertion est fausse | s'exécutent quand même |
| une erreur survient | passent en skip |
skipped dans le résumé JSON : ce n'est pas un détail cosmétique, c'est une partie de la suite qui n'a rien prouvé.Le contrôle négatif, appliqué aux tests
- Copier le module dans un répertoire temporaire.
- Casser un seul comportement : retirer une
validation, changer un défaut, remplacer un séparateur. - Rejouer la suite contre cette copie, et exiger un code de retour 1.
- Recommencer pour chaque comportement que la suite prétend couvrir.
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Anti-patterns des modules : les défauts qu'une suite de tests attrape avant l'appelant.
- Quiz Modules Terraform : un contrôle des acquis sur la conception et le test des modules.
- Le framework de tests : la référence officielle : blocs
run,variables,provideretexpect_failures. - Le mocking : la référence officielle :
mock_provider,override_resourceetoverride_module. - La commande test : la référence officielle : toutes les options,
-filter,-jsonet-verbosecomprises.