Le module cloud-run-service refuse allUsers depuis sa première version. Un jour, quelqu'un « simplifie » la validation en !contains(var.invokers, "allUsers") ; allAuthenticatedUsers (tout compte Google, y compris personnel) repasse. terraform validate est vert, le plan aussi, la revue n'y voit rien. Ce qui manquait : un test qui affirme ce que le module doit refuser, exécuté à chaque changement, sans projet Google Cloud ni identifiants.
Le mécanisme
terraform test exécute les fichiers tests/*.tftest.hcl d'un module. Chaque bloc run lance un plan ou un apply sur le module, avec ses variables, puis évalue des assert. Avec mock_provider "google" {}, aucune requête ne part : le provider simulé accepte toute configuration valide selon son schéma et invente les attributs calculés.
flowchart LR
T["tests/service.tftest.hcl"] --> R1["run : command = plan"]
T --> R2["run : command = apply"]
MP["mock_provider google<br/>(schéma réel, aucun appel)"] --> R1 & R2
R1 --> U["attributs calculés : inconnus"]
R2 --> V["attributs calculés : valeurs inventées<br/>(ou mock_resource defaults)"]
U & V --> A{"assert / expect_failures"}
A -- "toutes vraies" --> OK["pass"]
A -- "une fausse" --> KO["fail + error_message"]
Trois choses à savoir avant d'écrire le premier test :
command = planlaisse les attributs calculés (email,id,uri) inconnus. C'est la situation du vrai premier plan : tout ce qui casse ici cassera au premier déploiement.command = applyavec un provider simulé ne crée rien : il remplit les inconnues par des valeurs aléatoires, ou par celles d'unmock_resource … defaults. On s'en sert pour tester des sorties qui dépendent de valeurs calculées.expect_failures = [var.invokers]inverse le test : lerunpasse seulement si la validation de cette variable échoue. C'est ainsi qu'on teste un refus.
La mise en œuvre
Le test qui aurait attrapé la régression :
mock_provider "google" {}
variables {
project_id = "projet-test"
region = "europe-west1"
name = "triage"
image = "europe-west1-docker.pkg.dev/projet-test/images/triage@sha256:abc"
service_account_email = "run@projet-test.iam.gserviceaccount.com"
}
run "prive_et_protege_par_defaut" {
command = plan
assert {
condition = google_cloud_run_v2_service.this.ingress == "INGRESS_TRAFFIC_INTERNAL_ONLY"
error_message = "Par défaut, le service ne doit pas être joignable depuis Internet."
}
}
run "refuse_un_service_public" {
command = plan
variables {
invokers = ["allUsers"]
}
expect_failures = [var.invokers]
}Les tests de la composition platform vérifient le câblage entre modules. Deux pièges y ont été rencontrés, d'où ces mock_resource :
mock_provider "google" {
# Les valeurs aléatoires du mock ne passent pas la validation du provider
# pour les identités IAM : on fournit des valeurs au bon format.
mock_resource "google_logging_project_sink" {
defaults = { writer_identity = "serviceAccount:service-0@gcp-sa-logging.iam.gserviceaccount.com" }
}
mock_resource "google_service_account" {
defaults = {
email = "lab-ttg-run@projet-test.iam.gserviceaccount.com"
member = "serviceAccount:lab-ttg-run@projet-test.iam.gserviceaccount.com"
}
}
}
run "prod_protegee_et_privee" {
command = apply # provider simulé : rien n'est créé, les inconnues reçoivent les defaults
variables {
env = "prod"
image = "europe-west1-docker.pkg.dev/projet-test/images/triage@sha256:abc"
with_network = true
}
assert {
condition = output.runtime_service_account == "lab-ttg-run@projet-test.iam.gserviceaccount.com"
error_message = "Le service tourne sous le compte de service du module identities."
}
}La cible make test initialise et teste chaque module qui a un dossier tests/, puis lance les tests des politiques (leçon 3) :
cd files && make setup && make testRésultat obtenu (2026-10-16)
infra/modules/buckets : Success! 3 passed, 0 failed.
infra/modules/cloud-run-service : Success! 5 passed, 0 failed.
infra/modules/network : Success! 2 passed, 0 failed.
infra/modules/platform : Success! 4 passed, 0 failed.
infra/modules/service-accounts : Success! 3 passed, 0 failed.
8 tests, 8 passed, 0 warnings, 0 failures, 0 exceptions, 0 skipped17 tests Terraform en 4 s. Le module network n'est jamais appliqué dans ces labs (API Compute non activée) : ces deux tests sont sa seule exécution.
Le test sert aussi à prouver une décision de conception. Le projet précédent indexe les lecteurs d'un secret par une clé statique (ticketing-token/run) plutôt que par l'identité. En remettant la version « clé = identité » dans une copie du module secrets, le test platform en command = plan échoue :
run "dev_sans_image_pas_de_service"... fail
Error: Invalid for_each argument
on ../secrets/main.tf line 43, in resource "google_secret_manager_secret_iam_member" "accessors":
43: for_each = local.accessors
│ local.accessors will be known only after apply
The "for_each" map includes keys derived from resource attributes that cannot
be determined until apply, and so Terraform cannot determine the full set of
keys that will identify the instances of this resource.L'adresse e-mail d'un compte de service créé dans le même plan est inconnue au plan. Sans test, cette erreur n'apparaît qu'au premier plan sur un projet vierge, typiquement le jour de la création de la prod.
Ce qui casse en production
- Tests seulement en
applysimulé. Les valeurs inventées masquent les inconnues : unfor_eachsur une valeur calculée passe enapplysimulé et casse au vrai premier plan. Parade : au moins unrunencommand = planpar composition. - Mock trop libre. Le provider simulé ne connaît pas les règles de l'API (unicité des noms, quotas, rôles inexistants) :
roles/aiplatform.usrpasse le test. Parade : unevalidationdans le module pour ce qui compte, et un plan réel en CI (leçon 4). - Valeur aléatoire refusée par le provider.
writer_identityinventé ("x7Kf…") utilisé commememberIAM : le provider rejette le format. Symptôme : un test qui échoue sur une ligne qui n'a rien à voir avec l'assertion. Parade :mock_resource … defaultsau bon format. .terraformpartagé entre tests et vraie racine.terraform testinitialise le dossier du module ; c'est sans conséquence pour un module, mais dans une racine avec backend, l'init de test peut tenter de joindre le bucket d'état. Parade : tester les modules, pas les racines ; valider les racines avec-backend=falsedans unTF_DATA_DIRséparé.
La règle de décision
Testez avec un provider simulé tout ce que le module décide : défauts, refus, câblage, nommage. Ne testez pas ce que le provider ou l'API décide (un bucket est-il vraiment créé ?) : c'est le rôle d'un test d'intégration réel, plus lent et payant, à lancer rarement. Un test par validation (expect_failures), un test par défaut de sécurité, un test en plan par composition.
Pratique
Votre test platform passe en command = apply avec le provider simulé. Le premier plan réel sur un projet vierge échoue sur « Invalid for_each argument ». Qu'ajoutez-vous à la suite de tests ?
En apply simulé, le mock fournit des valeurs : les clés de for_each deviennent connues et l'erreur disparaît. En plan, elles restent inconnues, exactement comme lors d'un premier plan réel. Des defaults plus réalistes aggravent le masquage ; un test réel détecterait l'erreur, mais dix fois plus lentement et en payant.
Vous voulez garantir qu'un module refuse max_instances = 100. Quel bloc run écrivez-vous ?
expect_failures fait passer le run seulement si la validation de cette variable échoue : c'est le test d'un refus. Un assert ne s'évalue jamais si la validation échoue avant ; tester le défaut ne dit rien du refus ; le provider simulé n'appelle aucune API.
Exercice. Ajoutez au module buckets un test qui vérifie que, avec force_destroy = true, le bucket accepte la suppression (deletion_policy = "DELETE").
Voir la solution
run "supprimable_en_dev" {
command = plan
variables {
force_destroy = true
}
assert {
condition = alltrue([for b in google_storage_bucket.this : b.force_destroy && b.deletion_policy == "DELETE"])
error_message = "Avec force_destroy, destroy doit pouvoir supprimer le bucket."
}
}À ajouter dans infra/modules/buckets/tests/buckets.tftest.hcl, puis make test : infra/modules/buckets : Success! 4 passed. Ce test et proteges_hors_dev encadrent les deux branches de la même expression.
- J'écris un test natif avec
mock_provideret je choisis entrecommand = planetapply. - Je teste un refus avec
expect_failures. - Je fournis des
mock_resource … defaultsquand le provider valide le format d'un attribut calculé. - Je sais ce qu'un provider simulé ne peut pas détecter.