Testing Modules
The idea in one sentence
Section titled “The idea in one sentence”terraform validate, terraform plan, and terraform test each catch a completely different class of mistake, and passing one of them tells you nothing about whether the others would pass too.
Three different levels of confidence
Section titled “Three different levels of confidence”It is tempting to treat validate, plan, and test as interchangeable ways to check whether a module “works,” but they are not — each one talks to a different layer of reality.
terraform validate only checks HCL syntax and internal configuration consistency: are your blocks well-formed, do referenced variables and resources actually exist, are types consistent. Crucially, it never talks to AWS at all. A module can pass validate cleanly and still fail the instant it touches a real AWS account, because validate has no way to know whether your IAM permissions, your account’s service quotas, or an AWS API even agrees with what you wrote.
terraform plan closes that gap by talking to the real AWS API. It catches provider-side errors — a typo in an AMI ID, a region where a resource type is not available, a permission your credentials do not have — and shows you the diff of what would change. But plan does not assert anything specific about the result being correct. It happily shows you a diff for a bucket with the wrong name, a subnet in the wrong CIDR range, or a security group with an unintended open port, as long as that diff is internally consistent.
terraform test is the only one of the three that actively asserts specific expected behavior. It is Terraform’s native testing framework, and it is what actually tells you the module does what you meant it to do, not just that Terraform could parse or apply it.
Terraform’s native test framework
Section titled “Terraform’s native test framework”Tests live in .tftest.hcl files and run with terraform test. A test file has a variables block for test-specific input values, and one or more run blocks, each containing an assert block with a condition expression and an error_message string shown when that condition is false.
variables { bucket_name = "acme-app-logs-test"}
run "bucket_name_matches_input" { command = plan
assert { condition = aws_s3_bucket.this.bucket == var.bucket_name error_message = "Bucket name did not match the bucket_name input variable" }}This test targets the s3-bucket module pattern from earlier in this module: it sets bucket_name as a test-specific input, then asserts that the resulting aws_s3_bucket.this.bucket attribute matches exactly what was passed in. If someone edits the module and accidentally hardcodes a bucket name, or passes the wrong variable through, this assertion fails with a clear message pointing at the mistake — something validate and plan alone would never catch.
command = plan versus command = apply
Section titled “command = plan versus command = apply”Every run block has a command argument, and the choice between plan and apply is a real trade-off, not a stylistic preference.
command = plan is fast and creates no real resources. Terraform builds the plan, and your assertions check the planned values — exactly what happened in the example above. This is the right default for the majority of test cases, since it validates expected behavior without ever touching a live AWS account.
command = apply actually creates real resources, asserts on 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 problems plan cannot, like an AWS API rejecting a combination of arguments that looked fine on paper. The cost is that it is slower and briefly provisions real cloud resources, so it is worth reserving for the assertions that specifically need to observe real AWS behavior.
run "bucket_is_created_with_correct_name" { command = apply
assert { condition = aws_s3_bucket.this.bucket == var.bucket_name error_message = "Real bucket name in AWS did not match the bucket_name input variable" }}mock_provider, override_resource, override_module, and expect_failures
Section titled “mock_provider, override_resource, override_module, and expect_failures”Terraform 1.7 added a fourth tool that sits between plan and real apply: mocking. mock_provider replaces a real provider entirely — every resource it manages gets provider-generated mock values for any attribute your config does not set explicitly, so command = apply can run start to finish with zero real API calls and zero real credentials. This is the harness’s own primary proof mechanism for this course, and it is proven fully below with a real, complete module and a real .tftest.hcl file — no AWS account anywhere in the loop.
The module under test — a small aws_s3_bucket with a nested logging module and a variable validation:
resource "aws_cloudwatch_log_group" "this" { name = "/app/${var.app_name}" retention_in_days = var.retention_days}
output "log_group_arn" { value = aws_cloudwatch_log_group.this.arn}variable "app_name" { type = string}
variable "retention_days" { type = number}terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } }}
variable "bucket_name" { type = string validation { condition = length(var.bucket_name) >= 3 error_message = "bucket_name must be at least 3 characters." }}
variable "app_name" { type = string default = "web"}
resource "aws_s3_bucket" "this" { bucket = var.bucket_name}
module "logging" { source = "./modules/logging" app_name = var.app_name retention_days = 14}The test file — mock_provider "aws" {} at the top applies to every run block in the file that does not declare its own:
mock_provider "aws" {}
variables { bucket_name = "acme-app-logs-test"}
run "bucket_name_matches_input" { command = plan
assert { condition = aws_s3_bucket.this.bucket == var.bucket_name error_message = "Bucket name did not match the bucket_name input variable" }}
run "override_logging_module_output" { command = plan
override_module { target = module.logging outputs = { log_group_arn = "arn:aws:logs:us-east-1:123456789012:log-group:/app/fake" } }
assert { condition = module.logging.log_group_arn == "arn:aws:logs:us-east-1:123456789012:log-group:/app/fake" error_message = "override_module did not replace the logging module output" }}
run "override_bucket_resource" { command = plan
override_resource { target = aws_s3_bucket.this override_during = plan values = { arn = "arn:aws:s3:::overridden-bucket" } }
assert { condition = aws_s3_bucket.this.arn == "arn:aws:s3:::overridden-bucket" error_message = "override_resource did not replace the computed arn attribute" }}
run "bucket_name_too_short_fails_validation" { command = plan
variables { bucket_name = "ab" }
expect_failures = [ var.bucket_name, ]}
run "bucket_is_created_with_correct_name" { command = apply
assert { condition = aws_s3_bucket.this.bucket == var.bucket_name error_message = "Real bucket name did not match the bucket_name input variable" }}Real terraform test output — every run genuinely executed, zero AWS credentials configured anywhere:
$ terraform testtests/s3_bucket.tftest.hcl... in progress run "bucket_name_matches_input"... pass run "override_logging_module_output"... pass run "override_bucket_resource"... pass run "bucket_name_too_short_fails_validation"... pass run "bucket_is_created_with_correct_name"... passtests/s3_bucket.tftest.hcl... tearing downtests/s3_bucket.tftest.hcl... pass
Success! 5 passed, 0 failed.Four mechanisms, each proven by one of those five runs:
override_modulereplaces an entire child module’s outputs with fixed values, without executing that module’s own resources at all — proven above:module.logging.log_group_arnresolved to the literal fake ARN, not a mock-generated one, letting a test isolate the root module’s own logic from a dependency’s behavior.override_resourcereplaces one specific resource’s attributes. The real, non-obvious nuance proven above: this test’s first attempt (not shown — see the misconception below) tried overridingarnundercommand = planwithoutoverride_during = plan, and failed with “Unknown condition value… executed a run block with command = plan and one of the values your condition depended on is not known until after the plan has been applied.” Addingoverride_during = planto theoverride_resourceblock is what makes the overridden value available for a plan-time assertion — proven by the pass above once that argument was added.expect_failuresproves avariablevalidationblock actually rejects bad input — proven above: passingbucket_name = "ab"(2 characters) to arunthat namesvar.bucket_nameinexpect_failurespasses the test precisely because the validation failed as expected; if the validation had not fired, this run would fail instead.command = applywithmock_providerstill runs a real, full apply-and-destroy cycle — just against mock-generated resources instead of real AWS ones. Proven above: it passed identically to theplan-only runs, and this course’s harness uses exactly this mechanism (mock_provider+command = apply) to prove.tftest.hclfences genuinely execute, offline, for every cloud in this course.
flowchart TB a["terraform validate: HCL syntax and internal consistency only, never talks to AWS"] --> b["terraform plan: talks to the real AWS API, catches provider-side errors, shows the diff"] b --> c["terraform test: asserts specific expected behavior via .tftest.hcl run blocks"]
Mental Model
Section titled “Mental Model”Each of the four rungs answers a narrower question than the last, and that narrowing is the point. validate asks “is this HCL internally coherent?” plan asks “what would a real provider do with this?” test with command = plan asks “does the planned shape match what I meant?” test with command = apply (real or mock_provider) asks “does the applied shape match what I meant?” mock_provider is not a fifth rung — it is the same apply question asked without paying the cost (time, credentials, real infrastructure) of a real provider. override_resource/override_module narrow the question further still: “holding everything else fixed, does this one piece behave as I expect?” — proven above, override_during = plan is the detail that decides whether that narrowed answer is available before or only after a real apply.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
mock_provider + command = apply | Full apply-and-destroy cycle with zero real credentials or infrastructure — proven above, the entire test suite ran with no AWS account | Mock-generated attribute values are not real provider behavior; a real API rejecting a valid-looking argument combination still needs a genuine apply against real AWS to catch |
override_resource with override_during = plan | Assert on a specific overridden attribute before paying for a real apply — proven above | Easy to reach for plan by default and get a real “Unknown condition value” error, proven above, if this argument is left off |
override_module | Isolates the module under test from a dependency’s real behavior entirely — proven above, the child module’s own resources never even execute | The dependency’s own real behavior is never tested by this run; it needs its own separate test |
expect_failures | Proves a validation block actually rejects bad input, not just that it compiles — proven above | The run “passing” means the validation failed as expected — reading the test output correctly takes a moment to get used to |
Common Misconceptions
Section titled “Common Misconceptions”- “
override_resource’svaluesalways take effect for acommand = planassertion.” Proven above by the real failure encountered while writing this proof: withoutoverride_during = plan, the overridden value stays unknown until apply, and the assertion fails with “Unknown condition value … executed a run block with command = plan.” - “
command = applyinside a test always touches real infrastructure.” Proven above: withmock_providerin effect,applyruns a full create-and-destroy cycle against mock-generated resources — genuinely exercising the apply code path without ever calling AWS. - “
override_modulestill executes the child module’s own resources, just with different inputs.” Proven above: the overriddenmodule.logging.log_group_arnreturned the literal fake string, not something derived fromaws_cloudwatch_log_group.this— the child module’s resources are replaced entirely, not re-planned with different values. - “A
runblock usingexpect_failuresis a test that is allowed to fail.” Proven above: the run passes specifically because the named object’s validation failed — if the validation had incorrectly let the bad input through, this run would be the one reporting failure.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated .tftest.hcl files, check for:
override_resourceoverriding a computed attribute undercommand = planwith nooverride_during = plan— proven above, this fails with “Unknown condition value,” not silently passing with the wrong value.command = applyused throughout an entire test suite with a real provider (nomock_provider), when most of the assertions only needcommand = plan— proven above,planis faster and touches nothing real, and should be the default per this lesson’s original guidance.- A test file asserting on a resource’s attributes with no
mock_providerand no real credentials configured anywhere — such a file cannot passcommand = applyat all; check whethermock_providerwas meant to be there. expect_failuresnaming the wrong checkable object (assertingvar.bucket_namefailed when the intent was to test the resource’s own postcondition, or vice versa) — the test can pass for the wrong reason if the listed object happens to fail regardless.- Multiple
runblocks in one file where an earliercommand = applyrun’s real created state changes what a latercommand = planrun sees — proven implicitly above by why theapplyrun in this lesson’s own test file is ordered last: an earlier apply’s resulting state can make a later plan-time override behave differently than it would fresh.
🐛 Spot the bug
run "check_bucket_arn" {command = plan
override_resource { target = aws_s3_bucket.this values = { arn = "arn:aws:s3:::overridden-bucket" }}
assert { condition = aws_s3_bucket.this.arn == "arn:aws:s3:::overridden-bucket" error_message = "override did not take effect"}}Show the answer
Proven above with the real error this produces: arn is a computed attribute, unknown until aws_s3_bucket.this is actually created — under command = plan, Terraform has not resolved it yet, so the override’s replacement value is not available for the assertion either, unless the override_resource block explicitly says override_during = plan. Without it, this run fails with “Unknown condition value … one of the values your condition depended on is not known until after the plan has been applied,” not a quiet pass with the wrong value and not a silent no-op — the test fails loudly, but for a reason that looks like a bug in the module rather than a missing test argument. The fix is one line: override_during = plan inside the override_resource block, proven above to make the exact same assertion pass.