Chapter 12: Testing, Policy & Validation
Topic 77

The terraform test Framework

Testing

terraform test is Terraform's native testing framework, GA since 1.6 and written in HCL. It exercises a module by running real plan or apply operations and asserting on the result — no separate language, no Go toolchain. You write run blocks in .tftest.hcl files; each one runs the module with given variables and checks outputs and resource attributes with assert blocks.

The feature that makes it genuinely useful for a GCP module is provider mocking. With mock_provider "google" you can test that the Hatch ingest module wires its pieces together correctly without creating a single resource — no project, no credentials, no bill. That turns module testing from a slow, costly integration exercise into something fast enough to run on every pull request.

The Shape of a Test

A .tftest.hcl file holds optional variables, optional provider configuration, and one or more ordered run blocks. Each run declares a commandplan or apply — and carries assert blocks, each with a condition and an error_message. The condition is any HCL boolean expression over outputs and planned or created attributes; if it is false, the run fails and prints your message.

The runs within one file share state in order, so a later run can build on an earlier one. The framework discovers tests/*.tftest.hcl, executes each file, and exits non-zero on any failed assertion — which is what makes it a CI gate rather than a manual exercise.

A mocked plan-mode test of the ingest module
# tests/wiring.tftest.hcl
mock_provider "google" {}        # synthetic values, zero GCP calls

run "bucket_feeds_notification" {
  command = plan

  variables {
    project_id = "hatch-pipeline-dev"
    name       = "events"
  }

  assert {
    condition     = google_storage_notification.raw.bucket == google_storage_bucket.raw.name
    error_message = "notification must attach to the raw bucket"
  }
}

That run never touches Google Cloud. It plans the module with mocked provider data and asserts the notification's bucket argument resolves to the bucket the module created — a wiring check, run in seconds, for nothing.

plan-mode vs apply-mode Runs

A command = plan run asserts on the planned values without touching GCP — it is fast, free, and the workhorse of the suite. A command = apply run actually creates the resources, asserts against the real created attributes, and auto-destroys them when the run finishes. That second form is a true integration test: it needs credentials, it bills you, and it proves the resources really create and connect.

The split maps onto cost directly. Plan-mode runs are the ones you run on every PR because they cost nothing; apply-mode runs are the few you run on a slower schedule against a sandbox project because each one provisions and tears down real Cloud Run services, buckets, and Pub/Sub topics. Reach for apply only when you must prove behavior that the plan cannot show.

Two run modes, two costs
command = plan + mock_provider
Asserts on planned values with synthetic data, touches no GCP; fast, free, unit-tests wiring — run on every PR.
command = apply
Creates real resources, asserts on real attributes, auto-destroys; integration, needs credentials, costs money — run rarely.

Provider Mocking

mock_provider "google" returns synthetic values for computed attributes — the values that would normally be "known after apply." That lets a plan-mode run reason about the module's logic end to end: you can assert that the bucket name flows into the Pub/Sub notification, that a conditional creates the right number of resources, that a default resolves as intended — all with zero GCP calls and zero cost.

The crucial framing is what you are testing. A mocked test unit-tests your module's wiring, not Google Cloud's behavior. It proves your HCL connects the pieces correctly; it does not prove GCP will accept the request. That second question is what apply-mode integration tests answer, and the two are complementary, not redundant.

Unit Tests vs Integration Tests

Mocked plan-mode runs are unit tests of the module's logic — defaults, conditionals, computed references, validation rules. They are cheap, so you write many: one per branch of behavior the module exposes. Apply-mode runs against a real hatch-pipeline-dev are integration tests that prove the resources actually create and connect against GCP. They are expensive, so you write few — a handful that cover the critical paths.

The shape you want is the familiar test pyramid: many fast mocked unit tests, a few slow real-apply integration tests. A suite that is all integration tests is slow and costs money on every run; a suite that is all unit tests never proves the module works against real Google Cloud. You want both, weighted toward the cheap end.

Running and Wiring Into CI

terraform test discovers every tests/*.tftest.hcl file, runs each run block in order — sharing state within a file — and exits non-zero on any failed assertion. The mocked plan-mode tests run in PR CI, offline and credential-free, on every push. The real-apply integration tests run against a disposable sandbox project on a slower cadence, because each one costs time and money.

One operational hazard is worth planning for: an apply-mode run that fails partway can leak resources before its auto-destroy runs, leaving a globally-unique bucket or a Cloud Run service that needs manual cleanup. Give integration tests their own sandbox project and randomized resource names so a failure is contained and never collides with another run.

plan-mode vs apply-mode

command = plan — asserts on planned values, touches no GCP, costs nothing. With mock_provider it unit-tests the module's wiring offline. Run these on every PR; write many.

command = apply — creates real resources, asserts on real attributes, auto-destroys at the end. Needs credentials and bills you; a failed run can leak resources. Run these in a sandbox project on a slower schedule; write few.

Common Mistakes
  • Writing only command = apply tests and burning minutes and dollars per run when a mock_provider plan test would have caught the same wiring bug offline in seconds.
  • Forgetting that an apply run creates real billable resources and assuming it is a dry run — it provisions the Cloud Run service and bucket, then destroys them, but a failed run can leak resources that need manual cleanup.
  • Asserting on a computed attribute that is (known after apply) inside a plan-mode run and getting an unknown-value failure — that value is not resolved until apply, so mock it or move the assertion to an apply run.
  • Pointing apply-mode integration tests at a shared project and colliding with another run's globally-unique bucket name — give integration tests their own sandbox project and randomized names.
  • Skipping error_message strings so a failed run says only "assertion failed" instead of naming the contract that broke, leaving whoever sees the CI log to reverse-engineer it.
Best Practices
  • Write the bulk of tests as mock_provider plan-mode runs so the suite is fast, free, and runs on every PR.
  • Reserve command = apply runs for a few true integration tests against a disposable sandbox project, on a slower schedule than PR CI.
  • Assert with specific error_message strings so a failing run says what contract broke, not just "assertion failed."
  • Keep tests in tests/ next to the module and run terraform test in CI, failing the build on any non-zero exit.
  • Give apply-mode tests a sandbox project and randomized, globally-unique names so a failed run is contained and never collides with another.
Comparable tools Terratest Go-based integration testing, heavier — the pre-1.6 standard kitchen-terraform · terraform-compliance predate the native framework Pulumi native unit and property testing in its own ecosystem Config Connector tested via the Kubernetes test harness

Knowledge Check

Which kind of run block creates real, billable GCP resources?

  • A command = apply run, which provisions resources, asserts on them, then auto-destroys
  • A command = plan run, which quietly applies the computed plan to confirm it actually works
  • Any run that uses mock_provider, since the mocks transparently proxy real GCP API calls
  • Only the runs that completely omit an assert block from their body

What does mock_provider "google" let you test without GCP?

  • The module's own wiring and logic — that it connects resources correctly — using synthetic values for computed attributes
  • That Google Cloud will actually accept the configuration and successfully create every declared resource against the live API
  • The real, effective IAM permissions held by the service account running the test
  • Whether a globally-unique bucket name has already been taken by another project

Why does asserting a computed attribute inside a plan-mode run fail?

  • The value is (known after apply) and unresolved at plan time, so the assertion sees an unknown value
  • Plan-mode runs do not support any assert blocks at all, so the assertion is simply ignored
  • Computed attributes can only ever be referenced from inside the root module, never from a separate test file
  • The plan command strips all computed attributes out of the state before the assertion runs

What is the right balance of unit and integration tests in the native framework?

  • Many fast mocked plan-mode unit tests and a few slow real-apply integration tests
  • Only slow apply-mode tests, since plan-mode proves nothing at all about real GCP behavior
  • Only mocked plan-mode tests, since real apply-mode tests are never worth their billable cost
  • An exactly equal split, alternating between plan and apply runs for every single assertion

You got correct