Aller au contenu
Infrastructure as Code medium

Tester un module Terraform avec terraform test

35 min de lecture

logo terraform

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.

  • placer une suite pour tester un module, sans configuration autour
  • Comment s'écrit un run, avec ses assert et ses variables
  • 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 = plan et le mocking

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.hcl
Fenêtre de terminal
cd etiquette
terraform test

C'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é.

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.

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"... pass
tests/plaque.tftest.hcl... tearing down
tests/plaque.tftest.hcl... pass

Aprè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."
}
}

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 did
not.

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.

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é.

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.

C'est ici que se joue l'intégration continue, et c'est ce que la sortie humaine ne donne pas.

SituationCode de retour
tous les runs passent0
un run échoue1
aucun fichier de test trouvé0

Le flux JSONL est fait pour être consommé :

Fenêtre de terminal
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.

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 arriveLes runs suivants
une assertion est fausses'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"... skip

Un 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é.

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 :

  1. Copier le module dans un répertoire temporaire.

  2. Casser un seul comportement : retirer une validation, changer une valeur par défaut, remplacer un séparateur.

  3. Rejouer la suite contre cette copie, et exiger un code de retour 1.

  4. 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.

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 modules
required by this configuration.
SymptômeCause probableCorrection
Success! 0 passed, 0 failed.aucun fichier trouvé, ou -filter hors ciblevérifier le chemin, relatif au répertoire courant
Error: Module not installedinit non joué sur une configuration qui appelle un moduleterraform init
Reference to undeclared output valuel'output asséré n'existe pas dans le modulecorriger le nom, les runs suivants étaient en skip
Missing expected failurela garde attendue n'a pas rejeté la valeurrétablir la validation, ou corriger l'objet listé
An argument named "..." is not expected hereargument interdit dans le bloc module d'un runne garder que source et version
des ressources subsistent après un testle teardown a échouélire la fin de la sortie, nettoyer à la main
Terraform has no command named "test"binaire antérieur à la 1.6mettre Terraform à jour

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.

  • Une suite posée dans <module>/tests/ teste le module lui-même, sans projet enveloppe.
  • Un run applique par défaut : terraform test crée de la vraie infrastructure, puis la détruit.
  • Le bloc variables du fichier s'applique à tous les runs, celui d'un run l'emporte.
  • expect_failures est 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 bloc module limité à source et version.
  • Le mocking (1.7+) teste contre un provider simulé, sans rien créer.
  • Le code de retour vaut 0 ou 1, et le flux -json porte test_summary.
  • Un -filter hors cible rend 0 passed et 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.

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.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn