Testing Modules
The idea in one sentence
Section titled “The idea in one sentence”terraform validate, terraform plan, and terraform test each check a genuinely different thing — syntax, a real-world diff, and actual asserted behavior — and passing one does not mean the next would also pass.
Three different levels of confidence
Section titled “Three different levels of confidence”It is tempting to treat validate, plan, and test as three ways of asking the same question, but they check three different things, and each one catches a class of mistake the others miss entirely.
terraform validate checks that your HCL is syntactically well-formed and internally consistent — that every variable referenced actually exists, that types line up, that required arguments are present. Crucially, validate never talks to GCP at all. It has no credentials, makes no API calls, and cannot tell you whether a project_id is real or whether your account has permission to create a google_storage_bucket.
terraform validate# Success! The configuration is valid.terraform plan goes further: it authenticates to the real GCP API, reads the current state of any existing resources, and computes a diff of what would change. This catches an entire category of error validate cannot — an invalid location value the API rejects, a project_id your credentials cannot access, a quota limit. But plan still does not assert anything about whether the result is correct by your own definition; it only shows you what would happen.
terraform plan# google_storage_bucket.this will be created# + name = "acme-app-logs"# + location = "US"terraform test is the only one of the three that actively asserts specific expected behavior — that the bucket name really is what you passed in, that a given output really has the value you expect. Passing validate, and even a clean plan, tells you the configuration is well-formed and GCP accepted it; neither tells you the module actually does what you designed it to do.
Terraform’s native test framework
Section titled “Terraform’s native test framework”Terraform’s built-in test framework, introduced in Terraform 1.6, runs .tftest.hcl files through the terraform test command. A test file has a top-level variables block for test-specific input values, and one or more run blocks, each containing one or more assert blocks with a condition expression and an error_message shown if that condition is false:
variables { bucket_name = "acme-app-logs-test" project_id = "acme-app-test"}
run "bucket_name_matches_input" { command = plan
assert { condition = google_storage_bucket.this.name == var.bucket_name error_message = "Bucket name did not match the bucket_name input variable" }}This tests the gcs-bucket module from earlier in this module: it asserts that the bucket resource inside the module ends up with exactly the name the caller passed in as bucket_name, not just that the configuration is syntactically valid or that GCP would accept it.
command = plan versus command = apply
Section titled “command = plan versus command = apply”Every run block has a command argument, and the choice between its two values is a real tradeoff, not a formality. command = plan computes a plan and asserts against the planned values, without creating a single real resource. It is fast, free, and safe to run on every commit, which is why it should cover the majority of your test cases:
run "bucket_name_matches_input" { command = plan
assert { condition = google_storage_bucket.this.name == var.bucket_name error_message = "Bucket name did not match the bucket_name input variable" }}command = apply actually creates real resources in GCP, asserts against their real resulting attributes, and then tears everything down again at the end of the test file. This is genuine integration testing — it can catch things a plan-only test cannot, like a naming collision the GCP API only rejects at apply time — but it is slower and briefly costs real cloud resources, so reach for it deliberately rather than by default:
run "bucket_actually_created" { command = apply
assert { condition = google_storage_bucket.this.url != "" error_message = "Bucket was not actually created in GCP" }}flowchart LR v["terraform validate: HCL syntax and internal consistency only"] --> p["terraform plan: talks to the real GCP API, shows the diff"] --> t["terraform test: asserts specific expected behavior via .tftest.hcl"]