Terraform: Up & Running
Chapitre 7 / 8 · 22 min de lecture

Tester le code Terraform

Tester son infrastructure : tests manuels dans un environnement jetable, puis tests automatisés (unitaires, d'intégration, bout-en-bout) avec Terratest en Go.

Le monde du DevOps est plein de peur : peur de l'interruption de service, peur de la perte de données, peur de la faille de sécurité. Chaque fois que vous vous apprêtez à faire un changement, la même question revient : qu'est-ce que cela va casser ? Se comportera-t-il de la même façon dans tous les environnements ? À quelle heure de la nuit devrai-je rester debout cette fois-ci pour réparer ? Si vous gérez votre infrastructure as code, il existe une meilleure façon d'atténuer ce risque : les tests. Le but du test n'est pas de prouver l'absence de bug — c'est impossible — mais de vous donner la confiance nécessaire pour faire des changements. Si vous capturez tous vos processus d'infrastructure et de déploiement sous forme de code, vous pouvez tester ce code dans un environnement de pré-production ; et s'il marche là, il y a une forte probabilité qu'il marche aussi en production. Dans un monde fait de peur et d'incertitude, une forte probabilité et une grande confiance changent tout.

Les tests manuels : il n'y a pas de localhost

Pour penser le test du code Terraform, il est utile de tracer un parallèle avec un langage généraliste comme Ruby. Si vous écriviez un petit serveur web en Ruby, vous le testeriez manuellement en le lançant sur localhost, puis en l'interrogeant avec un navigateur ou avec curl. Faites une modification, vous redémarrez le serveur et relancez curl. Cycle simple, rapide, immédiat.

Quel est l'équivalent avec du code Terraform ? Reprenons le module alb (équilibreur de charge applicatif, Application Load Balancer) des chapitres précédents :

resource "aws_lb" "example" {
  name               = var.alb_name
  load_balancer_type = "application"
  subnets            = var.subnet_ids
  security_groups    = [aws_security_group.alb.id]
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.example.arn
  port              = local.http_port
  protocol          = "HTTP"

  # Par défaut, renvoyer une simple page 404
  default_action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/plain"
      message_body = "404: page not found"
      status_code  = 404
    }
  }
}

Une différence saute aux yeux face au code Ruby : vous ne pouvez pas déployer des ALB, des target groups, des listeners et des security groups AWS sur votre propre ordinateur. C'est le premier enseignement clé du chapitre : quand on teste du code Terraform, il n'y a pas de localhost. Cela vaut pour la plupart des outils d'infrastructure as code (IaC), pas seulement Terraform. Le seul moyen pratique de faire un test manuel est de déployer dans un environnement réel — c'est-à-dire dans AWS. Autrement dit, la façon dont vous avez lancé terraform apply puis terraform destroy tout au long du livre est déjà la façon de tester manuellement.

C'est précisément pourquoi il est essentiel d'avoir des exemples faciles à déployer dans le dossier examples de chaque module. Le plus simple pour tester le module alb à la main consiste à utiliser son exemple :

provider "aws" {
  region = "us-east-2"
  # Autoriser n'importe quelle version 2.x du provider AWS
  version = "~> 2.0"
}

module "alb" {
  source = "../../modules/networking/alb"

  alb_name   = "terraform-up-and-running"
  subnet_ids = data.aws_subnet_ids.default.ids
}

Vous déployez avec terraform apply, puis vous validez que l'action par défaut renvoie bien un 404 :

$ curl -s -o /dev/null -w "%{http_code}" 
    hello-world-stage-477699288.us-east-2.elb.amazonaws.com
404

À partir de là, vous itérez : chaque modification du code déclenche un nouveau terraform apply, suivi d'un curl pour vérifier le nouveau comportement, et un terraform destroy pour nettoyer à la fin. C'est exactement le même cycle qu'avec Ruby — déployer, valider, recommencer — sauf qu'il s'exécute contre une infrastructure réelle.

Note

Les exemples de ce chapitre utilisent curl et des requêtes HTTP parce que l'infrastructure testée comporte un équilibreur de charge qui répond en HTTP. Pour d'autres types d'infrastructure, la structure du test reste identique, mais l'étape de validation change : une base de données MySQL se valide avec un client MySQL, un serveur VPN avec un client VPN, un serveur qui n'écoute aucune requête en s'y connectant en SSH (Secure Shell) pour exécuter des commandes localement, et ainsi de suite.

L'environnement sandbox jetable

En faisant du test manuel, vous allez monter et démonter beaucoup d'infrastructure, et commettre beaucoup d'erreurs en chemin. Cet environnement doit donc être complètement isolé de vos environnements plus stables, comme la pré-production (staging) et surtout la production. Chaque équipe devrait se doter d'un environnement bac à sable (sandbox) isolé, où les développeurs peuvent créer et détruire ce qu'ils veulent sans craindre d'affecter les autres. Pour réduire les conflits — par exemple deux développeurs tentant de créer un load balancer du même nom — l'idéal absolu (gold standard) est que chaque développeur dispose de son propre environnement totalement isolé : avec AWS, son propre compte AWS.

Le nettoyage : un ramasse-miettes pour vos ressources

Multiplier les sandbox est essentiel à la productivité, mais sans discipline, vous vous retrouverez avec de l'infrastructure qui tourne partout, encombre tous vos environnements et vous coûte une fortune. D'où le deuxième enseignement clé : nettoyez régulièrement vos environnements sandbox. Au minimum, instaurez une culture où chacun détruit ce qu'il a déployé avec terraform destroy une fois ses tests terminés. Mieux : automatisez ce ménage avec un ramasse-miettes des ressources, lancé périodiquement (par exemple via une tâche cron) :

OutilCe qu'il fait
cloud-nukeSupprime toutes les ressources plus anciennes qu'un certain âge ; motif courant : cloud-nuke aws --older-than 48h une fois par jour dans chaque sandbox
Janitor Monkey / SwabbieNettoie les ressources AWS sur un calendrier configurable, avec notification du propriétaire avant suppression (projet Netflix Simian Army, repris par Swabbie)
aws-nukeDétruit tout dans un compte AWS, ciblé via un fichier YAML de configuration (régions, comptes, types de ressources)

Attention

Les tests d'infrastructure manipulent de vraies ressources, donc ils coûtent de l'argent et prennent du temps. Un compte sandbox oublié avec quelques instances RDS, ASG et ALB encore en vie peut faire grimper la facture sans bruit. Le ramasse-miettes automatique — supprimer tout ce qui dépasse quelques jours — n'est pas un luxe, c'est une protection budgétaire.

Les tests automatisés

L'idée du test automatisé est d'écrire du code de test qui valide que votre vrai code se comporte comme il le devrait. Au chapitre suivant, vous verrez comment un serveur d'intégration continue (CI) lance ces tests après chaque commit, pour immédiatement corriger ou annuler tout commit qui les casse, et garder ainsi le code toujours dans un état fonctionnel. On distingue classiquement trois familles :

  • Tests unitaires (unit tests) — ils vérifient le bon fonctionnement d'une petite unité de code isolée (en programmation générale, une fonction ou une classe), en remplaçant les dépendances externes par des doublures (mocks) pour contrôler finement les scénarios.
  • Tests d'intégration (integration tests) — ils vérifient que plusieurs unités fonctionnent correctement ensemble, avec un mélange de dépendances réelles et de mocks.
  • Tests bout-en-bout (end-to-end tests) — ils déploient toute l'architecture (applications, magasins de données, équilibreurs de charge) et la valident dans son ensemble, du point de vue de l'utilisateur final, sans aucun mock.

Chaque famille attrape des bugs différents ; on les combine donc. Les tests unitaires offrent un cycle de retour rapide et valident que les briques de base fonctionnent ; mais des briques correctes isolément ne s'assemblent pas forcément bien, d'où les tests d'intégration ; et ce qui marche dans nos environnements ne marche pas forcément dans le monde réel, d'où les tests bout-en-bout.

À retenir

Écrire des tests automatisés pour du code d'infrastructure n'est pas pour les âmes sensibles : c'est sans doute la partie la plus exigeante du sujet. Vous n'avez pas besoin d'exécuter le code Ruby (il sert juste à construire le modèle mental), mais vous voudrez écrire et lancer un maximum de code Go.

Tests unitaires : on ne peut pas faire de test unitaire pur

La première étape est d'identifier ce qu'est une « unité » dans le monde Terraform. L'équivalent le plus proche d'une fonction ou d'une classe est un module générique unique, comme le module alb. Comment le tester ?

En Ruby, écrire un test unitaire suppose de réduire au minimum les dépendances complexes (serveur HTTP, requête, réponse), au besoin par injection de dépendances et mocks. Or, si l'on réfléchit à ce que fait votre code Terraform — appeler l'API AWS pour créer le load balancer, les listeners, les target groups... — on réalise que 99 % de ce code consiste à communiquer avec des dépendances complexes ! Il n'y a aucun moyen pratique de ramener à zéro le nombre de dépendances externes ; et si vous y parveniez, il ne resterait plus rien à tester.

C'est le troisième enseignement clé : on ne peut pas faire de test unitaire pur avec du code Terraform. Les « tests unitaires » Terraform sont en réalité des tests d'intégration. On garde néanmoins le nom « unitaire » pour souligner l'objectif : tester une seule unité (un module générique) afin d'obtenir un retour aussi vite que possible. La stratégie est donc :

  Stratégie d'un test unitaire Terraform

  1. Créer un module générique et autonome
  2. Créer pour ce module un exemple facile à déployer
  3. terraform apply  →  déployer l'exemple dans un vrai environnement
  4. Valider que ce qui est déployé fonctionne
     (pour un ALB : envoyer une requête HTTP, vérifier la réponse)
  5. terraform destroy  →  nettoyer à la fin du test

Ce sont exactement les étapes du test manuel, mais capturées sous forme de code. C'est même un bon modèle mental : demandez-vous « comment l'aurais-je testé à la main pour avoir confiance ? », puis implémentez ce test. Tous les tests du livre sont écrits en Go, pour profiter de Terratest, une bibliothèque open source qui sait tester une grande variété d'outils IaC (Terraform, Packer, Docker, Helm) sur de nombreux environnements (AWS, Google Cloud, Kubernetes). Terratest est une sorte de couteau suisse, avec un support de première classe pour la stratégie ci-dessus : apply, valider, puis destroy.

L'anatomie d'un test Terratest

On dirige d'abord Terratest vers le code Terraform via le type terraform.Options, on déploie avec un assistant qui enchaîne init et apply, on lit une sortie (output), on interroge l'URL en HTTP, et on garantit la destruction finale. Voici le test unitaire complet du module alb :

package test

import (
	"fmt"
	"github.com/gruntwork-io/terratest/modules/http-helper"
	"github.com/gruntwork-io/terratest/modules/terraform"
	"testing"
	"time"
)

func TestAlbExample(t *testing.T) {
	opts := &terraform.Options{
		// Adaptez ce chemin relatif vers votre dossier d'exemple alb !
		TerraformDir: "../examples/alb",
	}

	// Tout nettoyer à la fin du test
	defer terraform.Destroy(t, opts)

	// Déployer l'exemple
	terraform.InitAndApply(t, opts)

	// Récupérer l'URL de l'ALB depuis l'output
	albDnsName := terraform.OutputRequired(t, opts, "alb_dns_name")
	url := fmt.Sprintf("http://%s", albDnsName)

	// Vérifier que l'action par défaut renvoie un 404
	expectedStatus := 404
	expectedBody := "404: page not found"

	maxRetries := 10
	timeBetweenRetries := 10 * time.Second

	http_helper.HttpGetWithRetry(
		t,
		url,
		expectedStatus,
		expectedBody,
		maxRetries,
		timeBetweenRetries,
	)
}

Trois points méritent l'attention. D'abord, OutputRequired renvoie la valeur de la sortie alb_dns_name (déclarée comme output dans l'exemple) et fait échouer le test si elle est vide. Ensuite, les retries : il s'écoule un bref laps de temps entre la fin de terraform apply et la propagation du nom DNS du load balancer. Une requête immédiate pourrait échouer alors qu'une minute plus tard tout fonctionnerait. Ce comportement asynchrone et éventuellement cohérent (eventually consistent) est normal dans AWS — et dans la plupart des systèmes distribués. La solution est HttpGetWithRetry, qui réessaie jusqu'à dix fois, dix secondes entre chaque, avant de conclure à l'échec.

Enfin, le defer. La destruction doit toujours avoir lieu, même si le test échoue avant. Dans d'autres langages on emploie un try / finally ; en Go, c'est le mot-clé defer, qui garantit l'exécution de la fonction passée au moment où la fonction englobante se termine, quelle que soit la manière dont elle se termine. Notez que le defer terraform.Destroy est placé tôt, avant même InitAndApply : ainsi rien ne peut faire échouer le test avant d'avoir programmé la destruction et laisser de l'infrastructure orpheline.

$ go test -v -timeout 30m
TestAlbExample (...) Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
TestAlbExample (...) Making an HTTP GET call to URL http://...elb.amazonaws.com
TestAlbExample (...) Destroy complete! Resources: 5 destroyed.
PASS
ok      terraform-up-and-running        229.492s

Piège courant

Notez le -timeout 30m. Par défaut, Go tue tout test dépassant dix minutes — non seulement il échoue, mais le code de nettoyage (terraform destroy) ne s'exécute pas, laissant des ressources en vie. Dès qu'un test Go déploie de la vraie infrastructure, donnez-lui un timeout généreux pour éviter qu'il soit interrompu en plein milieu.

Injection de dépendances : minimiser les dépendances externes

Même si le test unitaire pur est impossible avec Terraform, il reste judicieux de minimiser les dépendances externes. Prenons le module hello-world-app, qui suppose que le module mysql est déjà déployé et exige qu'on lui passe les détails du bucket S3 où mysql stocke son état. Une convention utile est de regrouper toutes les dépendances externes dans un fichier dependencies.tf, pour les rendre visibles d'un coup d'œil :

data "terraform_remote_state" "db" {
  backend = "s3"
  config = {
    bucket = var.db_remote_state_bucket
    key    = var.db_remote_state_key
    region = "us-east-2"
  }
}

data "aws_vpc" "default" {
  default = true
}

Comment injecter ces dépendances depuis l'extérieur pour les remplacer au moment du test ? La réponse est déjà connue : les variables d'entrée. On ajoute une variable pour chaque dépendance, avec une valeur par défaut à null :

variable "mysql_config" {
  description = "La configuration de la base MySQL"
  type = object({
    address = string
    port    = number
  })
  default = null
}

La valeur null est précieuse ici : elle indique qu'une variable n'est pas renseignée et que l'utilisateur veut revenir au comportement par défaut. Une valeur vide (chaîne ou liste vide) ne permettrait pas de distinguer « valeur par défaut » d'« valeur vide voulue par l'utilisateur ». On rend ensuite les data sources conditionnelles avec le paramètre count, et on choisit via des valeurs locales entre la variable injectée et la data source :

data "terraform_remote_state" "db" {
  count   = var.mysql_config == null ? 1 : 0
  backend = "s3"
  config = {
    bucket = var.db_remote_state_bucket
    key    = var.db_remote_state_key
    region = "us-east-2"
  }
}

locals {
  mysql_config = (
    var.mysql_config == null
      ? data.terraform_remote_state.db[0].outputs
      : var.mysql_config
  )
}

Comme mysql_config utilise le constructeur de type object qui correspond exactement aux outputs du module mysql, on peut composer les deux modules en une ligne (mysql_config = module.mysql) : si les types divergent un jour, Terraform le signale aussitôt. C'est de la composition de fonctions typée. Dans le test, on injecte alors des données fictives via le paramètre Vars :

opts := &terraform.Options{
	TerraformDir: "../examples/hello-world-app/standalone",
	Vars: map[string]interface{}{
		"mysql_config": map[string]interface{}{
			"address": "mock-value-for-test",
			"port":    3306,
		},
	},
}

Le parallélisme et le nommage par espaces

Quatre à cinq minutes pour un test, ce n'est pas terrible pour de l'infrastructure ; mais avec des dizaines de tests exécutés en séquence, la suite complète peut durer des heures. Pour raccourcir la boucle de retour, on lance le maximum de tests en parallèle. En Go, il suffit d'ajouter t.Parallel() en tête de chaque test :

func TestHelloWorldAppExample(t *testing.T) {
	t.Parallel()
	// ...
}

Un piège surgit aussitôt : plusieurs tests créent des ressources (ASG, security group, ALB) du même nom, ce qui provoque des collisions. Même sans t.Parallel(), plusieurs développeurs lançant les mêmes tests, ou une exécution en CI, déclencheraient ces conflits. D'où le quatrième enseignement clé : vous devez nommer toutes vos ressources par espaces (namespace). Concevez modules et exemples de sorte que le nom de chaque ressource soit configurable, puis donnez-lui une valeur unique au test grâce à l'assistant random.UniqueId() de Terratest :

Vars: map[string]interface{}{
	"alb_name": fmt.Sprintf("test-%s", random.UniqueId()),
},

UniqueId() renvoie une chaîne aléatoire de six caractères en base 62 (plus de 56 milliards de combinaisons), assez courte pour rester sous les limites de longueur des noms AWS, assez aléatoire pour rendre les conflits hautement improbables. On peut alors lancer un grand nombre de tests en parallèle sans crainte de collision.

Astuce

Pour exécuter plusieurs tests en parallèle contre le même dossier Terraform, ils se marcheraient sur les pieds : tous lancent terraform init et écrasent mutuellement leur dossier .terraform et leurs fichiers d'état. La solution la plus simple est que chaque test copie le dossier dans un répertoire temporaire unique. Terratest le fait nativement avec test_structure.CopyTerraformFolderToTemp, en préservant le bon fonctionnement des chemins relatifs.

Tests d'intégration : faire collaborer plusieurs modules

Si une « unité » Terraform est un module unique, un test d'intégration doit déployer plusieurs modules et vérifier qu'ils fonctionnent ensemble. Plutôt que d'utiliser des données fictives, déployons le module mysql pour de vrai et assurons-nous que hello-world-app s'y intègre correctement. Le squelette du test suit la même chorégraphie que le test unitaire, avec deux déploiements imbriqués et deux defer :

func TestHelloWorldAppStage(t *testing.T) {
	t.Parallel()

	// Déployer la base MySQL
	dbOpts := createDbOpts(t, dbDirStage)
	defer terraform.Destroy(t, dbOpts)
	terraform.InitAndApply(t, dbOpts)

	// Déployer hello-world-app
	helloOpts := createHelloOpts(dbOpts, appDirStage)
	defer terraform.Destroy(t, helloOpts)
	terraform.InitAndApply(t, helloOpts)

	// Valider que hello-world-app fonctionne
	validateHelloApp(t, helloOpts)
}

Un point délicat concerne le backend. Les valeurs du backend S3 sont codées en dur et pointent sur l'état réel de la pré-production : les laisser telles quelles écraserait le vrai fichier d'état de staging ! La bonne solution est la configuration partielle : on déplace toute la configuration du backend dans un fichier externe backend.hcl et on laisse le bloc backend vide dans le code :

terraform {
  backend "s3" {
  }
}

En production on passe terraform init -backend-config=backend.hcl ; au test, Terratest fournit des valeurs adaptées via le paramètre BackendConfig, avec une clé d'état qui inclut l'uniqueId pour rester unique à chaque exécution :

return &terraform.Options{
	TerraformDir: terraformDir,
	Vars: map[string]interface{}{
		"db_name":     fmt.Sprintf("test%s", uniqueId),
		"db_password": "password",
	},
	BackendConfig: map[string]interface{}{
		"bucket":  bucketForTesting,
		"region":  bucketRegionForTesting,
		"key":     dbStateKey,
		"encrypt": true,
	},
}

Le hello-world-app lit ensuite l'état exact que mysql vient d'écrire : on règle ses db_remote_state_bucket et db_remote_state_key sur les valeurs du BackendConfig du module mysql. La validation utilise HttpGetWithRetryWithCustomValidation, qui autorise une règle sur mesure — ici, vérifier que le corps contient « Hello, World » plutôt que de l'égaler exactement, car le script de User Data renvoie une page HTML enrichie. Ce test d'intégration est plus complexe que l'unitaire et prend plus du double de temps (10 à 15 minutes au lieu de 4 à 5) : le goulot d'étranglement est le temps qu'AWS met à déployer et détruire RDS, ASG, ALB...

Les étapes de test (test stages) : accélérer le cycle de développement

Le test d'intégration enchaîne cinq étapes distinctes : déployer mysql, déployer hello-world-app, valider, détruire hello-world-app, détruire mysql. En CI, on veut tout exécuter de bout en bout. Mais en développement local, si vous n'itérez que sur hello-world-app, payer à chaque fois le prix du déploiement et de la destruction de mysql ajoute 5 à 10 minutes de pur surcoût par exécution. L'idéal serait une boucle interne rapide : déployer une fois les deux modules, puis modifier hello-world-app, le re-déployer, valider, recommencer — et ne détruire qu'à la fin.

Terratest le permet nativement avec le paquet test_structure. On enveloppe chaque étape dans une fonction nommée, et on peut sauter certaines étapes en positionnant des variables d'environnement. Chaque étape stocke ses données sur le disque pour qu'une exécution ultérieure puisse les relire :

func TestHelloWorldAppStageWithStages(t *testing.T) {
	t.Parallel()

	stage := test_structure.RunTestStage

	// Déployer la base MySQL
	defer stage(t, "teardown_db", func() { teardownDb(t, dbDirStage) })
	stage(t, "deploy_db", func() { deployDb(t, dbDirStage) })

	// Déployer hello-world-app
	defer stage(t, "teardown_app", func() { teardownApp(t, appDirStage) })
	stage(t, "deploy_app", func() { deployApp(t, dbDirStage, appDirStage) })

	// Valider hello-world-app
	stage(t, "validate_app", func() { validateApp(t, appDirStage) })
}

Chaque étape sauvegarde et recharge ses options via test_structure.SaveTerraformOptions et LoadTerraformOptions. Le passage par le disque (et non par la mémoire) est indispensable : chaque étape peut tourner dans une exécution différente, donc un processus différent. On peut alors instruire Terratest de sauter toute étape nommée foo en posant SKIP_foo=true. Le flux de travail typique devient :

# 1. Tout déployer, mais sauter les destructions (l'infra reste en vie)
$ SKIP_teardown_db=true SKIP_teardown_app=true 
    go test -timeout 30m -run 'TestHelloWorldAppStageWithStages'

# 2. Itérer : sauter aussi le déploiement de mysql (déjà en vie),
#    ne réexécuter que apply + validation de hello-world-app
$ SKIP_teardown_db=true SKIP_teardown_app=true SKIP_deploy_db=true 
    go test -timeout 30m -run 'TestHelloWorldAppStageWithStages'
# PASS  ok  terraform-up-and-running  13.824s

# 3. Tout nettoyer : ne lancer que les étapes de destruction
$ SKIP_deploy_db=true SKIP_deploy_app=true SKIP_validate_app=true 
    go test -timeout 30m -run 'TestHelloWorldAppStageWithStages'

La différence est spectaculaire : au lieu d'attendre 10 à 15 minutes après chaque changement, on essaie une nouvelle modification en 10 à 60 secondes. Comme on relance ces étapes des dizaines, voire des centaines de fois, le gain est énorme. Cela ne change rien à la durée des tests en CI, mais l'impact en environnement de développement est considérable.

Les retries : composer avec les tests instables

Dès que vous lancez régulièrement des tests d'infrastructure, vous rencontrerez des tests instables (flaky tests) : des échecs occasionnels pour des raisons transitoires (instance EC2 qui refuse de démarrer, bug de cohérence éventuelle de Terraform, erreur de handshake TLS avec S3). Le monde de l'infrastructure est désordonné ; attendez-vous à des échecs intermittents et gérez-les. Terratest permet d'ajouter des retries pour des erreurs connues :

return &terraform.Options{
	TerraformDir: terraformDir,
	// Réessayer jusqu'à 3 fois, 5 s entre chaque, sur les erreurs connues
	MaxRetries:         3,
	TimeBetweenRetries: 5 * time.Second,
	RetryableTerraformErrors: map[string]string{
		"RequestError: send request failed": "Souci de throttling ?",
	},
}

Les clés de RetryableTerraformErrors sont les messages d'erreur à repérer dans les logs (expressions régulières acceptées) ; les valeurs, des informations supplémentaires à afficher au moment du retry.

Tests bout-en-bout : le changement incrémental

Le dernier type de test est le test bout-en-bout : déployer tout dans un environnement qui imite la production et le tester du point de vue de l'utilisateur final. On pourrait reprendre la stratégie des tests d'intégration — quelques dizaines d'étapes pour apply, valider, destroy — mais on le fait rarement en pratique, à cause de la pyramide des tests.

              ▲   peu de tests
             /E2E   lents, fragiles, coûteux
            /─────
           /  INTÉG    modérés
          /──────────
         /  UNITAIRES     nombreux : rapides, fiables, peu chers
        /───────────────
              base

L'idée : beaucoup de tests unitaires (la base), moins de tests d'intégration (le milieu), encore moins de tests bout-en-bout (le sommet). Plus on monte, plus le coût d'écriture, la fragilité et la durée d'exécution augmentent. D'où le cinquième enseignement clé : les petits modules sont plus faciles et plus rapides à tester. On veut donc tester le plus bas possible dans la pyramide, là où la boucle de retour est la plus rapide et la plus fiable.

Au sommet, déployer une architecture complète depuis zéro devient intenable pour deux raisons. Trop lent : monter puis démonter toute l'architecture peut prendre des heures ; vous ne lanceriez la suite que la nuit, ce qui limite à une tentative de correction par jour — et finit par pousser les équipes à ignorer les échecs. Trop fragile : plus on déploie de ressources, plus la probabilité d'une erreur intermittente grimpe. Si une ressource a 0,1 % de chances d'échouer, la probabilité de succès suit la formule 99,9 % ^ N :

Type de testRessourcesProbabilité de succès
Unitaire (1 module)~2098,0 %
Intégration (3 modules)~6094,1 %
Bout-en-bout (30 modules)~60054,9 %

Près de la moitié des exécutions bout-en-bout échoueraient pour des raisons transitoires ! On colmate certaines erreurs avec des retries, mais cela tourne vite à la partie de taupes sans fin. C'est pourquoi peu d'entreprises à l'infrastructure complexe lancent des tests bout-en-bout qui déploient tout depuis zéro. La stratégie courante est différente :

  1. UNE fois : payer le coût de déploiement d'un environnement
     persistant, de type production, appelé « test », qu'on laisse tourner.

  2. À CHAQUE changement d'infrastructure, le test bout-en-bout :
     a. applique le changement INCRÉMENTAL à l'environnement « test » ;
     b. lance des validations (par ex. Selenium, du point de vue
        de l'utilisateur final) pour vérifier que tout marche.

En n'appliquant que des changements incrémentaux, on passe de plusieurs centaines de ressources déployées à une poignée : les tests sont plus rapides et moins fragiles. Surtout, cette approche imite la façon dont vous déployez réellement en production — vous ne reconstruisez pas la production de zéro à chaque changement. Vous testez donc non seulement que l'infrastructure marche, mais que son processus de déploiement marche aussi. Par exemple, on peut valider qu'une mise à jour se déploie sans interruption de service : en faisant varier la variable server_text (qui force un déploiement progressif via le module asg-rolling-deploy), tout en interrogeant l'URL en continu en arrière-plan avec une goroutine, et en échouant si une seule réponse n'est pas un 200 OK.

func redeployApp(t *testing.T, helloAppDir string) {
	helloOpts := test_structure.LoadTerraformOptions(t, helloAppDir)
	albDnsName := terraform.OutputRequired(t, helloOpts, "alb_dns_name")
	url := fmt.Sprintf("http://%s", albDnsName)

	// Vérifier en continu, chaque seconde, que l'app répond 200 OK
	stopChecking := make(chan bool, 1)
	waitGroup, _ := http_helper.ContinuouslyCheckUrl(t, url, stopChecking, 1*time.Second)

	// Changer le texte du serveur et redéployer (déploiement progressif)
	newServerText := "Hello, World, v2!"
	helloOpts.Vars["server_text"] = newServerText
	terraform.Apply(t, helloOpts)

	// S'assurer que la nouvelle version est bien déployée
	http_helper.HttpGetWithRetryWithCustomValidation(t, url, 10, 10*time.Second,
		func(status int, body string) bool {
			return status == 200 && strings.Contains(body, newServerText)
		})

	// Arrêter la vérification continue
	stopChecking <- true
	waitGroup.Wait()
}

Si ce test passe, c'est que hello-world-app et asg-rolling-deploy réalisent bien un déploiement progressif sans interruption, comme promis.

Les autres approches de test

Au-delà de la démarche Terratest, deux catégories complètent la panoplie, et attrapent des bugs différents.

L'analyse statique examine votre code sans l'exécuter. terraform validate vérifie la syntaxe et les types (un peu comme un compilateur). tflint est un linter qui repère les erreurs courantes et bugs potentiels selon un jeu de règles intégrées. HashiCorp Sentinel est un cadre de « politique as code » qui impose des règles transverses — par exemple interdire toute règle de security group ouvrant un accès entrant à 0.0.0.0/0 (disponible avec les produits Terraform Enterprise).

Le test de propriétés valide des « propriétés » précises de l'infrastructure déployée : kitchen-terraform, rspec-terraform, serverspec, inspec, goss. Ces outils offrent un langage dédié (DSL) déclaratif et concis pour vérifier qu'un serveur respecte une spécification — droits sur un fichier, dépendance installée, port à l'écoute :

describe file('/etc/myapp.conf') do
  it { should exist }
  its('mode') { should cmp 0644 }
end

describe port(8080) do
  it { should be_listening }
end

Leur force : concision et déclarativité, idéales pour des listes d'exigences de conformité (PCI, HIPAA...). Leur limite : toutes les vérifications de propriétés peuvent passer alors que l'infrastructure ne fonctionne toujours pas. Là où la « voie Terratest » enverrait une vraie requête HTTP au serveur pour vérifier qu'il renvoie bien la réponse attendue.

À retenir

Tout change sans cesse dans le monde de l'infrastructure : Terraform, Packer, Docker, Kubernetes, AWS... Le code d'infrastructure pourrit donc très vite. Pour le dire crûment : du code d'infrastructure sans tests automatisés est cassé — au sens littéral. À chaque fois que l'on prend le temps d'écrire des tests automatisés, on déterre des bugs non triviaux, dans son propre code comme dans les outils utilisés. C'est un effort considérable, mais les heures passées sur la logique de retry et la cohérence éventuelle se rachètent par les heures qu'on ne passera pas à 3 h du matin sur une panne.

À retenir

  • Il n'y a pas de localhost en Terraform : tout test manuel se fait en déployant de vraies ressources dans un ou plusieurs environnements sandbox isolés et jetables — idéalement un compte AWS par développeur.
  • Nettoyez régulièrement vos sandbox : terraform destroy systématique, plus un ramasse-miettes automatique (cloud-nuke, Janitor Monkey, aws-nuke) qui supprime tout ce qui dépasse quelques jours, sous peine de voir les coûts s'envoler.
  • On ne fait pas de test unitaire pur : les « tests unitaires » Terraform sont des tests d'intégration qui déploient un module générique isolé, le valident (HTTP/SSH avec retries pour la cohérence éventuelle), puis le détruisent via defer placé tôt.
  • L'anatomie d'un test Terratest : terraform.Optionsdefer DestroyInitAndApply → lecture des outputs → HttpGetWithRetry ; lancez avec go test -timeout 30m pour ne pas être tué à 10 minutes.
  • Nommez tout par espaces (random.UniqueId()) et activez t.Parallel() : indispensable pour faire tourner les tests en parallèle, en CI et à plusieurs, sans collision de noms.
  • Découpez en étapes de test (test_structure + variables SKIP_*) pour itérer en quelques secondes au lieu de quinze minutes ; ajoutez des retries (RetryableTerraformErrors) contre les tests instables.
  • Respectez la pyramide des tests : beaucoup d'unitaires, moins d'intégration, peu de bout-en-bout. Les petits modules se testent plus vite et plus fiablement (99,9 % ^ N) ; testez les changements bout-en-bout de façon incrémentale sur un environnement « test » persistant. Complétez avec l'analyse statique (terraform validate, tflint, Sentinel) et le test de propriétés (inspec, goss).