Lección 1 — Tester

Tests natifs et providers simulés

7 min de lectura

Este contenido aún no está traducido. Se muestra la versión original.

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 = plan laisse 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 = apply avec un provider simulé ne crée rien : il remplit les inconnues par des valeurs aléatoires, ou par celles d'un mock_resource … defaults. On s'en sert pour tester des sorties qui dépendent de valeurs calculées.
  • expect_failures = [var.invokers] inverse le test : le run passe 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 :

files/infra/modules/cloud-run-service/tests/service.tftest.hcl (extrait)
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 :

files/infra/modules/platform/tests/platform.tftest.hcl (extrait)
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 test

Ré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 skipped

17 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 apply simulé. Les valeurs inventées masquent les inconnues : un for_each sur une valeur calculée passe en apply simulé et casse au vrai premier plan. Parade : au moins un run en command = plan par 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.usr passe le test. Parade : une validation dans 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_identity inventé ("x7Kf…") utilisé comme member IAM : 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 … defaults au bon format.
  • .terraform partagé entre tests et vraie racine. terraform test initialise 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=false dans un TF_DATA_DIR sé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 ?

Vous voulez garantir qu'un module refuse max_instances = 100. Quel bloc run écrivez-vous ?

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_provider et je choisis entre command = plan et apply.
  • Je teste un refus avec expect_failures.
  • Je fournis des mock_resource … defaults quand le provider valide le format d'un attribut calculé.
  • Je sais ce qu'un provider simulé ne peut pas détecter.
Siguiente