Tester avec terraform test
Pourquoi
Les trois premières leçons ont produit des modules réutilisables et versionnés. Reste une question que le versionnement ne règle pas : comment savoir qu'une nouvelle version fait ce qu'elle promet ? Jusqu'ici, la réponse est « on lance un plan et on lit ». C'est une vérification humaine, lente, et qui ne protège que le jour où quelqu'un la fait. Le module reseau change quand même : une variable ajoutée, une validation resserrée, une ressource conditionnelle. Chaque changement peut rompre sans bruit une promesse faite aux appelants : l'adresse réservée a disparu, le bastion s'ouvre à tout Internet, le nom d'une ressource a changé de forme.
Un test automatisé transforme une promesse en vérification exécutable à chaque modification. Pour de l'infrastructure, trois obstacles existaient : tester demande de créer de vraies ressources (lent, coûteux, avec des identifiants), la sortie d'un plan n'est pas facile à interroger, et les erreurs de configuration se produisent souvent avant tout appel d'API. Depuis Terraform 1.6, la commande terraform test (tofu test dans OpenTofu) répond à ces trois points : des fichiers de test écrits en HCL, des assertions sur le plan ou sur l'état, et depuis Terraform 1.7 des fournisseurs simulés qui permettent de tester sans créer quoi que ce soit.
Cette leçon montre le mécanisme sur un module qui n'utilise que des fournisseurs sans coût (random, local), avec des résultats réels, puis l'applique au module reseau de Signalements.
Les concepts
Les fichiers de test
Un test est un fichier *.tftest.hcl (ou *.tftest.json) placé à la racine du module ou dans un répertoire tests/, qui est celui que terraform test explore par défaut (-test-directory en choisit un autre). Il contient :
- un bloc optionnel
variablesde niveau fichier : des valeurs d'entrée communes à tous les tests du fichier ; - des blocs optionnels
provider,mock_provideretoverride_*; - une suite de blocs
run: chacun est un test, exécuté dans l'ordre.
Le module testé est celui du répertoire courant (ou celui qu'un bloc run désigne avec module { source = ... }).
Le bloc run
run "une_instance_par_zone" {
command = plan
assert {
condition = length(local_file.instance) == 2
error_message = "Il faut une instance par zone."
}
}- Le nom est l'étiquette du test, affichée dans les résultats.
commandvautplanouapply(la valeur par défaut).planne crée rien : les assertions portent sur le plan, donc sur ce que Terraform ferait.applycrée vraiment les ressources, évalue les assertions sur l'état, et les détruit en fin de fichier.- Chaque bloc
asserta unecondition(booléenne, qui peut lire les ressources, variables, sorties et valeurs locales du module) et unerror_message. Une assertion fausse fait échouer lerun. - Un
runpeut avoir son propre blocvariables, qui prend le pas sur celui du fichier. expect_failuresliste les objets (var.x,output.y, une ressource, uncheck) dont on attend qu'ils échouent leur validation ; le test réussit s'ils échouent, et il échoue s'ils réussissent.
Les blocs d'un même fichier partagent un état
Les run d'un même fichier s'exécutent séquentiellement sur le même état : un run en apply crée des ressources que le run suivant retrouve. Un run peut aussi lire les sorties d'un run précédent (run.creation.noms). À la fin du fichier, Terraform détruit tout ce que les run ont créé, dans l'ordre inverse des run. Chaque fichier de test a son propre état : deux fichiers ne partagent rien. Les run de command = plan ne modifient pas l'état.
Simuler : mock_provider et override
Le coût et les identifiants d'un test d'intégration disparaissent si le fournisseur est simulé. Introduits dans Terraform 1.7 :
mock_provider "nom" {}remplace le fournisseur par un faux : il accepte toute configuration, répond aux créations et aux lectures sans appel d'API, et fabrique des valeurs pour tous les attributs calculés (identifiants, adresses). Les tests enapplyfonctionnent alors sans compte ni réseau.override_resource(etoverride_data,override_module) remplace une ressource, une source de données ou un module précis par des valeurs que vous fixez (values = { id = "lapin" }). Utile pour donner des valeurs réalistes, ou pour rendre une valeur connue.- Par défaut, ces valeurs ne servent qu'à l'
apply: en plan, elles ne sont pas disponibles. L'attributoverride_during = plan(Terraform 1.11) les rend visibles aussi pendant un test en plan. - Les blocs
mock_resourceetmock_data, dans unmock_provider, définissent des valeurs par défaut pour un type de ressource.
Les trois niveaux de test
Morris et Brikman décrivent une pyramide : peu de tests lents et réels au sommet, beaucoup de tests rapides à la base. Avec terraform test :
| Niveau | Commande | Ce que cela vérifie | Coût |
|---|---|---|---|
| Unitaire | plan avec fournisseur simulé | la logique du module : noms, nombres, conditions, validations | quelques secondes, sans compte |
| Contrat | apply avec fournisseur simulé | que l'ensemble se compose : sorties, dépendances, références entre ressources | idem |
| Intégration | apply avec le vrai fournisseur | que la configuration est acceptée par l'API et se comporte | minutes, compte, argent |
Ce que les tests ne peuvent pas garantir : qu'un fournisseur simulé accepte des valeurs que l'API refuserait. Un mock_provider ne connaît pas les règles de l'API réelle (une zone qui n'existe pas, une plage de réseau invalide). Pour cela, il reste le test d'intégration, ou la validation à l'exécution.
Ce qu'il faut tester dans un module
- L'interface : les valeurs par défaut (un objet vide donne les bons défauts), les validations (les valeurs interdites sont refusées avec
expect_failures), les options (passerelle = nullne crée aucune ressource de passerelle). - Les invariants : un nom de ressource suit le schéma
sig-<env>-<zone>; chaque instance reçoit exactement une adresse ; aucune règle de pare-feu n'ouvre0.0.0.0/0sur le port 22. - Les sorties : leurs formes et leurs clés, parce que ce sont celles dont dépendent les appelants.
Ne testez pas le fournisseur (que scaleway_vpc crée bien un VPC : c'est le travail du fournisseur), ni des détails d'implémentation (le nom interne d'une ressource), qui rendraient chaque refactorisation douloureuse.
En pratique
Le module sous test
Pour la démonstration, un petit module à fournisseurs gratuits : il reçoit un environnement et un ensemble de zones, génère un nom par zone et écrit un fichier par instance.
# variables.tf
variable "environnement" {
description = "preprod ou prod."
type = string
validation {
condition = contains(["preprod", "prod"], var.environnement)
error_message = "L'environnement doit valoir preprod ou prod."
}
}
variable "zones" {
description = "Zones de disponibilité, une instance par zone."
type = set(string)
validation {
condition = length(var.zones) >= 1
error_message = "Au moins une zone est requise."
}
}# main.tf
resource "random_pet" "suffixe" {
length = 1
}
locals {
noms = { for z in var.zones : z => "sig-${var.environnement}-${z}" }
}
resource "local_file" "instance" {
for_each = local.noms
filename = "${path.module}/sortie/${each.value}.txt"
content = "instance ${each.value} (${random_pet.suffixe.id})\n"
}# outputs.tf
output "noms" {
description = "Noms des instances, indexés par zone."
value = local.noms
}Un test unitaire en plan
# tests/unitaire.tftest.hcl
variables {
environnement = "preprod"
zones = ["fr-par-1", "fr-par-2"]
}
run "une_instance_par_zone" {
command = plan
assert {
condition = length(local_file.instance) == 2
error_message = "Il faut une instance par zone."
}
assert {
condition = output.noms["fr-par-2"] == "sig-preprod-fr-par-2"
error_message = "Le nom suit le schéma sig-<env>-<zone>."
}
}
run "environnement_invalide" {
command = plan
variables {
environnement = "recette"
}
expect_failures = [var.environnement]
}Le premier run vérifie la logique sans rien créer. Le second teste la validation : avec environnement = "recette", la variable doit échouer ; expect_failures = [var.environnement] transforme cet échec en succès.
Un test d'intégration en apply
# tests/integration.tftest.hcl
variables {
environnement = "preprod"
zones = ["fr-par-1"]
}
run "creation" {
command = apply
assert {
condition = fileexists("${path.module}/sortie/sig-preprod-fr-par-1.txt")
error_message = "Le fichier de l'instance n'a pas été créé."
}
}Ici, le « fournisseur réel » est local : l'apply écrit un vrai fichier, l'assertion le vérifie, et la fin du fichier le détruit.
Lancer les tests
$ terraform init -no-color
$ terraform test -no-color
tests/integration.tftest.hcl... in progress
run "creation"... pass
tests/integration.tftest.hcl... tearing down
tests/integration.tftest.hcl... pass
tests/unitaire.tftest.hcl... in progress
run "une_instance_par_zone"... pass
run "environnement_invalide"... pass
tests/unitaire.tftest.hcl... tearing down
tests/unitaire.tftest.hcl... pass
Success! 3 passed, 0 failed.
La sortie dit tout : chaque fichier, ses run, la phase tearing down (destruction de ce qu'il a créé), puis le bilan. terraform init est nécessaire avant le premier test (il installe les fournisseurs).
Un test qui échoue
Un fichier avec trois run fautifs montre les messages utiles :
# tests/echec.tftest.hcl
variables {
environnement = "preprod"
zones = ["fr-par-1", "fr-par-2"]
}
run "nom_attendu" {
command = plan
assert {
condition = output.noms["fr-par-1"] == "sig-prod-fr-par-1"
error_message = "Le nom doit contenir prod."
}
}
run "valeur_inconnue_au_plan" {
command = plan
assert {
condition = random_pet.suffixe.id != ""
error_message = "Le suffixe doit exister."
}
}
run "sans_erreur_attendue" {
command = plan
variables {
zones = []
}
}$ terraform test -no-color -filter=tests/echec.tftest.hcl
tests/echec.tftest.hcl... in progress
run "nom_attendu"... fail
Error: Test assertion failed
on tests/echec.tftest.hcl line 10, in run "nom_attendu":
10: condition = output.noms["fr-par-1"] == "sig-prod-fr-par-1"
├────────────────
│ Diff:
│ --- actual
│ +++ expected
│ - "sig-preprod-fr-par-1"
│ + "sig-prod-fr-par-1"
Le nom doit contenir prod.
run "valeur_inconnue_au_plan"... fail
Error: Unknown condition value
on tests/echec.tftest.hcl line 19, in run "valeur_inconnue_au_plan":
19: condition = random_pet.suffixe.id != ""
├────────────────
│ random_pet.suffixe.id is a string
Condition expression could not be evaluated at this time. This means you have
executed a `run` block with `command = plan` and one of the values your
condition depended on is not known until after the plan has been applied.
Either remove this value from your condition, or execute an `apply` command
from this `run` block. Alternatively, if there is an override for this value,
you can make it available during the plan phase by setting `override_during =
plan` in the `override_` block.
run "sans_erreur_attendue"... skip
tests/echec.tftest.hcl... tearing down
tests/echec.tftest.hcl... fail
Failure! 0 passed, 2 failed, 1 skipped.
Trois enseignements :
- Le diff de l'assertion montre la valeur obtenue et la valeur attendue : le premier échec se lit sans relire le code.
- Un test en plan ne peut pas lire une valeur connue après l'apply. L'identifiant de
random_petn'existe qu'après la création. Le message le dit et propose deux sorties : passer cerunenapply, ou forcer la valeur avecoverride_during = plan. - Après un échec, les
runsuivants du fichier sont ignorés (skip). Un fichier est une séquence : si le troisième test dépend du deuxième, il ne sert à rien de l'exécuter. D'où l'intérêt de fichiers courts, un par thème, plutôt qu'un fichier unique qui masque les échecs après la première erreur. (Lefilterlimite l'exécution à un fichier.)
Simuler le fournisseur et forcer une valeur
Le fichier suivant teste la forme du contenu écrit sans toucher le disque, et donne une valeur connue au suffixe aléatoire :
# tests/mock.tftest.hcl
mock_provider "local" {}
override_resource {
target = random_pet.suffixe
values = {
id = "lapin"
}
}
variables {
environnement = "prod"
zones = ["fr-par-1", "fr-par-2"]
}
run "contenu_du_fichier" {
command = apply
assert {
condition = local_file.instance["fr-par-2"].content == "instance sig-prod-fr-par-2 (lapin)\n"
error_message = "Le contenu doit reprendre le nom et le suffixe."
}
}$ terraform test -no-color -filter=tests/mock.tftest.hcl
tests/mock.tftest.hcl... in progress
run "contenu_du_fichier"... pass
tests/mock.tftest.hcl... tearing down
tests/mock.tftest.hcl... pass
Le fournisseur local est simulé : aucun fichier n'a été écrit (le répertoire sortie/ est resté vide). L'option -verbose affiche l'état simulé et montre ce que le faux fournisseur a fabriqué : les attributs que vous avez fixés (content, filename) sont conservés, et les attributs calculés reçoivent des chaînes aléatoires (id, content_md5, file_permission) :
# local_file.instance["fr-par-2"]:
resource "local_file" "instance" {
content = <<-EOT
instance sig-prod-fr-par-2 (lapin)
EOT
content_md5 = "qlnhmlmc"
file_permission = "8munfn8n"
filename = "./sortie/sig-prod-fr-par-2.txt"
id = "9cluirlb"
...
}
Les chaînes comme 8munfn8n pour une permission de fichier le montrent : une assertion qui lit un attribut calculé d'une ressource simulée ne teste rien d'utile. Ne comparez que ce que votre configuration a décidé, ou donnez une valeur explicite avec mock_resource / override_resource.
Pour rendre le suffixe connu dès le plan, un run en plan peut porter lui-même un override_resource avec override_during = plan :
run "suffixe_au_plan" {
command = plan
override_resource {
target = random_pet.suffixe
override_during = plan
values = {
id = "lapin"
}
}
assert {
condition = random_pet.suffixe.id == "lapin"
error_message = "Le suffixe doit venir de la valeur forcée."
}
}Ce test réussit (testé avec Terraform 1.16.1) ; sans override_during = plan, il échouerait comme valeur_inconnue_au_plan. L'option n'existe que depuis Terraform 1.11.
Les rapports
terraform test -junit-xml=rapport.xml écrit un rapport au format JUnit XML (disponible en version stable depuis Terraform 1.11), que la plupart des outils d'intégration continue savent afficher.
Tester le module réseau de Signalements
Le test du module reseau de la leçon 1 suit le même schéma, avec le fournisseur Scaleway simulé : aucun compte, aucune clé, aucun appel d'API. Il se place dans modules/reseau/tests/reseau.tftest.hcl :
mock_provider "scaleway" {}
variables {
environnement = "preprod"
adresses_instances = {
"sig-app-1" = "172.16.20.11"
"sig-app-2" = "172.16.20.12"
}
}
run "une_adresse_par_instance" {
command = plan
assert {
condition = length(scaleway_ipam_ip.app) == 2
error_message = "Une adresse IPAM doit être réservée par instance."
}
assert {
condition = scaleway_vpc_private_network.app.ipv4_subnet[0].subnet == "172.16.20.0/22"
error_message = "Le sous-réseau par défaut doit être 172.16.20.0/22."
}
}
run "sans_passerelle_par_defaut" {
command = plan
assert {
condition = length(scaleway_vpc_public_gateway.passerelle) == 0
error_message = "Aucune passerelle ne doit être créée sans l'option passerelle."
}
}
run "bastion_ouvert_a_tout_internet" {
command = plan
variables {
passerelle = {
plages_autorisees = ["0.0.0.0/0"]
}
}
expect_failures = [var.passerelle]
}Ce fichier a été vérifié avec terraform init et terraform validate -test-directory=tests contre le fournisseur Scaleway 2.84.0, mais pas exécuté : aucun plan n'a été lancé avec le fournisseur Scaleway pendant la rédaction, même simulé, il n'y a donc pas de sortie réelle à vous montrer. Le troisième run suppose la validation de la leçon 2 qui refuse 0.0.0.0/0 (à ajouter au module si ce n'est pas fait). Lancez-le dans votre propre dépôt avec terraform test : un échec indiquerait que la simulation ne se comporte pas comme décrit ici.
Les tests d'intégration réels
Un test d'intégration sur Scaleway crée de vraies ressources (un VPC, un réseau privé, des adresses). Il demande :
- un projet Scaleway dédié aux tests, avec une clé d'API limitée à ce projet, et des quotas ;
- des ressources peu coûteuses (un VPC et un réseau privé sont gratuits ; une passerelle publique et une instance ne le sont pas) ;
- un nettoyage fiable (voir « Pièges »).
Il vérifie ce que la simulation ne sait pas : que l'API accepte la plage, les adresses, les étiquettes. On le lance moins souvent : avant un tag de version, ou chaque nuit, pas à chaque commit.
Sous le capot
Chaque run est un plan, éventuellement suivi d'un apply. Terraform charge le module, applique les variables du run (en superposant les niveaux : ligne de commande, fichier, run), puis exécute un plan complet. Pour command = apply, il applique et conserve l'état résultant en mémoire pour le run suivant. Les assertions sont des expressions évaluées dans la portée du module : elles voient var.*, local.*, les ressources, output.*, et run.<nom>.<sortie> pour les sorties des run précédents.
L'état d'un fichier de test est local et éphémère. Il n'est écrit dans aucun backend (les backend ne s'appliquent pas aux tests en version stable), et il disparaît avec le processus, sauf si la destruction finale échoue : Terraform affiche alors la liste des ressources orphelines. Depuis Terraform 1.11, l'attribut state_key d'un run permet à plusieurs run de partager un même état interne, y compris entre fichiers ; sans lui, chaque fichier a le sien. Terraform 1.14 a introduit, en expérimental et seulement dans les versions alpha, les backend et skip_cleanup dans les fichiers de test : ne vous y appuyez pas.
Un fournisseur simulé est un fournisseur sans serveur. mock_provider charge le schéma du vrai fournisseur (c'est pourquoi terraform init le télécharge toujours) et répond à chaque opération de création ou de lecture en renvoyant les valeurs de la configuration, plus des valeurs générées pour les attributs calculés. Il valide donc les noms d'arguments et leurs types, mais pas les règles métier de l'API.
La destruction se fait dans l'ordre inverse des run. À la fin du fichier, Terraform détruit les objets du dernier run qui en a créé, puis de l'avant-dernier, en remontant. Si un run a modifié une ressource déjà créée, c'est l'état final qui est détruit, avec la configuration du run concerné.
Les versions comptent. Le comportement de terraform test a beaucoup évolué : disponible en version stable depuis 1.6, simulation en 1.7, state_key et override_during en 1.11, exécution parallèle en 1.12 (-parallelism, et un attribut parallel sur les run). La déclaration required_version = ">= 1.11" du module (voir la leçon 1) couvre les fonctionnalités utilisées ici.
Pièges courants
Lire une valeur inconnue en plan. C'est l'erreur de loin la plus fréquente (voir plus haut) : tout attribut calculé par l'API (id, adresse, date) est inconnu en command = plan. Solutions : tester en apply avec simulation, ou forcer la valeur par override_during = plan.
Un test en apply réel qui laisse des ressources. Si un run échoue ou si le processus est interrompu (annulation CI, délai dépassé), la destruction finale peut ne pas avoir lieu. Les ressources restent dans le projet de test, avec leur coût. Prévoyez un balayeur (un travail nocturne qui supprime les ressources de test de plus de N heures, repérées par une étiquette comme test=terraform) et un projet dédié.
Des assertions sur des attributs simulés. Ce que renvoie un fournisseur simulé pour un attribut calculé est arbitraire. Une assertion dessus passe ou échoue au hasard.
Un fichier de test qui dépend de l'ordre d'un autre. Les fichiers ne partagent pas d'état. Un run qui suppose les ressources d'un autre fichier échoue. Dans un même fichier, c'est au contraire la règle : un run en apply prépare le suivant.
Des contraintes de version dans un bloc provider du fichier de test. Depuis Terraform 1.9, c'est refusé : les versions se déclarent dans le required_providers du module.
Sécurité
- Les tests simulés ne demandent aucun identifiant. C'est un avantage de sécurité majeur : ils peuvent tourner sur les demandes de fusion de dépôts publics ou de contributeurs externes, sans exposer la moindre clé. Réservez les secrets aux tests d'intégration, déclenchés depuis la branche principale ou à la demande.
- Un test d'intégration est du code qui s'exécute avec une clé d'API. Une demande de fusion qui modifie un fichier
.tftest.hclpeut créer des ressources dans votre compte de test. Ne donnez ces clés qu'à des travaux qui s'exécutent depuis une branche protégée, avec un projet dédié, des droits limités et des quotas. - Aucun secret dans les fichiers de test. Les valeurs sensibles se passent par variables d'environnement (
TF_VAR_*) ou par l'outil de CI. Un fichier.tftest.hclest versionné. - Testez les garde-fous. Les validations de sécurité du module (bastion non ouvert à tout Internet, base sans accès public) sont les premières à tester avec
expect_failures: un test qui échoue au premier relâchement de la règle vaut une revue. - Les ressources orphelines sont une exposition. Un réseau, une instance ou une base de test oubliée est une surface d'attaque et une dépense ; le balayeur est aussi une mesure de sécurité.
- Ne confondez pas test et politique. Un test dit que votre module respecte une règle ; une politique (leçon 6) vérifie que tout plan la respecte, y compris celui d'un module qui n'a pas de test.
En production
Où placer les tests dans la CI
Le pipeline de la leçon 12 du premier cours est étendu par étage :
- À chaque commit sur une demande de fusion :
terraform fmt -check,terraform validate,tflint, puisterraform testavec les fichiers simulés (rapides, sans identifiants). Si le dépôt a un répertoireexamples/,validatesur chaque exemple. - Avant un tag de version du module : les tests d'intégration réels, dans le projet de test, avec les identifiants du travail protégé.
- De nuit : le balayeur d'orphelins et un test d'intégration complet sur la branche principale, qui détecte les dérives de l'API ou du fournisseur sans que personne n'ait commité.
Une façon simple de séparer les niveaux est de les mettre dans des répertoires distincts (tests/unitaires/, tests/integration/) et de lancer terraform test -test-directory=tests/unitaires en premier étage. Le code de sortie de terraform test est non nul en cas d'échec, ce qui suffit à faire échouer l'étape.
Le coût et la durée
Les tests simulés durent quelques secondes par fichier et peuvent tourner en parallèle (-parallelism, parallel = true sur les run indépendants, depuis Terraform 1.12). Les tests d'intégration durent le temps de création des ressources réelles : plusieurs minutes pour une base managée. Ils servent à confirmer, pas à explorer. Si un test d'intégration est le seul à attraper une catégorie d'erreur, écrivez ensuite un test simulé qui l'attrape plus tôt.
Ce que les tests ne remplacent pas
Le plan relu reste la dernière barrière avant l'apply, les politiques vérifient les plans des modules tiers, et le comportement d'une application déployée relève d'outils de bout en bout (Terratest, scripts de vérification), hors du cadre de terraform test.
OpenTofu
tofu test partage les fichiers .tftest.hcl, les blocs run, assert, expect_failures, mock_provider, override_resource, override_data et override_module, ainsi que les options -filter et -test-directory. Il propose aussi une sortie JSON (-json, -json-into). Les différences se jouent sur le calendrier : des attributs comme override_during, state_key ou l'option -junit-xml sont des ajouts de Terraform 1.11 ; la documentation de tofu test consultée pour cette leçon n'en mentionne pas d'équivalent. Si vos tests doivent tourner avec les deux outils, limitez-vous au noyau commun (run, assert, variables, expect_failures, mock_provider, override_resource) et lancez la suite avec chaque outil en CI. D'après sa documentation, le mock_provider d'OpenTofu accepte aussi alias et for_each.
Exercices
Exercice 1 : tester une validation
Le module instance de la leçon 2 refuse un disque de moins de 10 Go. Écrivez un fichier de test qui vérifie (a) qu'un disque de 5 Go est refusé, (b) qu'un disque absent reçoit 20 Go.
Solution
variables {
nom = "sig-app-1"
}
run "disque_trop_petit" {
command = plan
variables {
disque = { taille_go = 5 }
}
expect_failures = [var.disque]
}
run "disque_par_defaut" {
command = plan
assert {
condition = output.configuration.disque.taille_go == 20
error_message = "La taille par défaut doit être 20 Go."
}
}Le premier run réussit parce que la validation de var.disque échoue comme attendu. Le second lit une sortie du module : en command = plan, elle est connue tant qu'elle ne dépend que de variables (testé avec Terraform 1.16.1 : les deux run réussissent).
Exercice 2 : corriger un test qui échoue au plan
Ce test échoue avec Unknown condition value. Proposez deux corrections.
run "suffixe_present" {
command = plan
assert {
condition = length(random_pet.suffixe.id) > 0
error_message = "Le suffixe doit exister."
}
}Solution
- Passer en
command = apply: l'identifiant est connu après la création (avec le vrai fournisseurrandom, qui ne coûte rien). - Rester en plan et forcer la valeur : ajouter dans le
runoverride_resource { target = random_pet.suffixe, override_during = plan, values = { id = "lapin" } }(Terraform 1.11 ou plus). Attention : ce test vérifie alors la valeur forcée, pas le fournisseur. Il ne vaut que s'il sert à tester ce que le module fait de cette valeur (par exemple dans un nom de fichier). Un test qui vérifie seulement qu'un attribut qu'on a soi-même fixé n'est pas vide n'apporte rien.
Exercice 3 : organiser une suite
Le dépôt lyneko-modules contient trois modules (reseau, app, base). Proposez l'organisation des tests et leur enchaînement en CI.
Solution
Un répertoire tests/ par module (modules/reseau/tests/, etc.), contenant unitaires/ (simulés, un fichier par thème : défauts, validations, options, sorties) et integration/ (un fichier par scénario réel, par exemple « réseau avec passerelle »). En CI : sur chaque demande de fusion, terraform test -test-directory=tests/unitaires pour chaque module modifié (détecté par chemin), plus validate sur examples/. Avant un tag reseau-v1.3.0 : terraform test -test-directory=tests/integration sur le module concerné, dans le projet de test. De nuit : intégration des trois modules et nettoyage des ressources étiquetées test=terraform de plus de quatre heures.
Récapitulatif
- Un test est un fichier
*.tftest.hclqui contient des blocsrunexécutés dans l'ordre, chacun avec desassertsur le plan (command = plan) ou sur l'état (command = apply). - Les
rund'un fichier partagent un état ; les fichiers sont indépendants ; la destruction finale se fait dans l'ordre inverse. expect_failuresteste qu'une validation échoue ; il fait réussir le test si c'est le cas.mock_provider(Terraform 1.7) etoverride_resourcepermettent de tester sans identifiants ni coût ; les attributs calculés simulés sont arbitraires, il ne faut pas les asserter.- En plan, une valeur inconnue fait échouer l'assertion : testez en apply simulé, ou forcez la valeur avec
override_during = plan(1.11). - Trois niveaux : unitaire (plan simulé), contrat (apply simulé), intégration (apply réel, projet dédié, balayeur d'orphelins).
- En CI : tests simulés à chaque commit, intégration avant un tag et de nuit, rapport JUnit (
-junit-xml, 1.11). tofu testpartage le noyau (run,assert,mock_provider,override_*) ; vérifiez les ajouts récents de Terraform avant de les utiliser avec OpenTofu.
Pour aller plus loin
- La documentation de
terraform testet detofu test, en particulier les pages sur les fournisseurs simulés et lesoverride_*. - Brikman, Terraform: Up & Running, chapitre 9 : la pyramide des tests d'infrastructure et ses compromis.
- La leçon 6 : vérifier des politiques sur chaque plan, au-delà des tests d'un module.
- La leçon 10 : s'appuyer sur ces tests pour monter de version sans casser.
Sources
- HashiCorp, Terraform : la commande terraform test
- HashiCorp, Terraform : écrire des tests
- HashiCorp, CHANGELOG de Terraform (versions 1.6 à 1.17)
- OpenTofu, la commande tofu test
- OpenTofu, CHANGELOG des versions 1.7 à 1.11
- Yevgeniy Brikman, Terraform: Up & Running (3e éd., O'Reilly, 2022), chapitre 9 : How to Test Terraform Code
- Kief Morris, Infrastructure as Code (3e éd., O'Reilly, 2025), chapitres sur le test de l'infrastructure