Aller au contenu

Tester avec terraform test

300 Concevoir ⏱ 1 h 20 terraformopentofuscalewayiachcl

À la fin, vous saurez

  • Écrire un fichier .tftest.hcl avec des blocs run, des assertions et des variables
  • Distinguer un test en plan, un test en apply et un test avec fournisseur simulé, et choisir selon l'objet testé
  • Tester qu'une validation de variable échoue avec expect_failures
  • Simuler un fournisseur ou une ressource avec mock_provider et override_resource
  • Placer les tests dans une chaîne d'intégration continue, avec leur coût et leur nettoyage

Prérequis

Testé avec local 2.5.3 random 3.9.1 scaleway 2.84.0 terraform 1.16.1 , vérifié le 5 octobre 2026

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 variables de niveau fichier : des valeurs d'entrée communes à tous les tests du fichier ;
  • des blocs optionnels provider, mock_provider et override_* ;
  • 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.
  • command vaut plan ou apply (la valeur par défaut). plan ne crée rien : les assertions portent sur le plan, donc sur ce que Terraform ferait. apply crée vraiment les ressources, évalue les assertions sur l'état, et les détruit en fin de fichier.
  • Chaque bloc assert a une condition (booléenne, qui peut lire les ressources, variables, sorties et valeurs locales du module) et un error_message. Une assertion fausse fait échouer le run.
  • Un run peut avoir son propre bloc variables, qui prend le pas sur celui du fichier.
  • expect_failures liste les objets (var.x, output.y, une ressource, un check) 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 en apply fonctionnent alors sans compte ni réseau.
  • override_resource (et override_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'attribut override_during = plan (Terraform 1.11) les rend visibles aussi pendant un test en plan.
  • Les blocs mock_resource et mock_data, dans un mock_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 :

NiveauCommandeCe que cela vérifieCoût
Unitaireplan avec fournisseur simuléla logique du module : noms, nombres, conditions, validationsquelques secondes, sans compte
Contratapply avec fournisseur simuléque l'ensemble se compose : sorties, dépendances, références entre ressourcesidem
Intégrationapply avec le vrai fournisseurque la configuration est acceptée par l'API et se comporteminutes, 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 = null ne 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'ouvre 0.0.0.0/0 sur 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 :

  1. Le diff de l'assertion montre la valeur obtenue et la valeur attendue : le premier échec se lit sans relire le code.
  2. Un test en plan ne peut pas lire une valeur connue après l'apply. L'identifiant de random_pet n'existe qu'après la création. Le message le dit et propose deux sorties : passer ce run en apply, ou forcer la valeur avec override_during = plan.
  3. Après un échec, les run suivants 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. (Le filter limite 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.hcl peut 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.hcl est 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 :

  1. À chaque commit sur une demande de fusion : terraform fmt -check, terraform validate, tflint, puis terraform test avec les fichiers simulés (rapides, sans identifiants). Si le dépôt a un répertoire examples/, validate sur chaque exemple.
  2. 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é.
  3. 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
  1. Passer en command = apply : l'identifiant est connu après la création (avec le vrai fournisseur random, qui ne coûte rien).
  2. Rester en plan et forcer la valeur : ajouter dans le run override_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.hcl qui contient des blocs run exécutés dans l'ordre, chacun avec des assert sur le plan (command = plan) ou sur l'état (command = apply).
  • Les run d'un fichier partagent un état ; les fichiers sont indépendants ; la destruction finale se fait dans l'ordre inverse.
  • expect_failures teste qu'une validation échoue ; il fait réussir le test si c'est le cas.
  • mock_provider (Terraform 1.7) et override_resource permettent 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 test partage 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 test et de tofu test, en particulier les pages sur les fournisseurs simulés et les override_*.
  • 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.
Voir ma constellation →

Sources