Testing Modules
The idea in one sentence
Section titled “The idea in one sentence”terraform validate, terraform plan, and terraform test each answer a different question — “is this syntactically legal,” “does Azure accept this,” and “does this behave the way I expect” — and treating any one of them as a substitute for the others is how broken modules reach production looking green the whole way there.
Three levels of confidence, and why they are not interchangeable
Section titled “Three levels of confidence, and why they are not interchangeable”It is tempting to treat “no errors” as “it works.” Terraform actually gives you three distinct checks, each catching a different class of mistake, and each one passing tells you almost nothing about the ones after it.
terraform validate checks HCL syntax and internal configuration consistency only. It confirms your blocks are well-formed, argument types match what a resource expects, and references like var.name or module.storage.primary_blob_endpoint point at something that actually exists. Crucially, validate never talks to Azure at all — it has no way of knowing that the VM size you typed does not exist in the region you picked, or that the storage account name you chose is already taken by someone else’s subscription. A configuration can pass validate cleanly and still fail the moment it touches the real Azure API.
terraform validate# Success! The configuration is valid.## ...but validate has no idea whether "Standard_ZZZ99" is a real VM size,# or whether "acmeapplogs" is already taken as a storage account name.terraform plan is a real step up: it authenticates to Azure, resolves data sources, and asks the provider what would happen. This is where region and SKU mistakes, naming collisions, and quota problems actually surface, because plan genuinely talks to the Azure API and shows you the diff it would apply. What plan does not do is assert anything about that diff being correct. It shows you a plan; it does not check the plan against your expectations. Nothing stops a plan from succeeding while quietly creating a storage account with the wrong replication type, because nobody told Terraform what “correct” means for this module.
terraform plan# Plan: 1 to add, 0 to change, 0 to destroy.## This confirms Azure accepts the request. It does not confirm the# storage account it is about to create actually has the settings# this module was supposed to guarantee.terraform test is the one that actively asserts something. It is Terraform’s native testing framework, and it is the only one of the three that encodes “here is the specific behavior I expect” and fails loudly the moment reality diverges from it. Where validate checks form and plan checks that Azure accepts the request, test checks the result.
Terraform’s native test framework: .tftest.hcl and terraform test
Section titled “Terraform’s native test framework: .tftest.hcl and terraform test”Test files live in a .tftest.hcl extension, conventionally under a tests/ directory, and run with the terraform test command. A test file is built from two kinds of block: a top-level variables block that supplies test-specific input values, and one or more run blocks, each of which executes the configuration and checks it with one or more assert blocks.
variables { resource_group_name = "rg-app-logs"}
run "creates_storage_account_with_expected_name" { command = plan
variables { name = "acmeapplogs" }
assert { condition = azurerm_storage_account.this.name == "acmeapplogs" error_message = "Storage account name did not match the name variable passed in" }}Each assert block needs exactly two things: a condition that must evaluate to true, and an error_message string that is shown to you the moment that condition is false. The variables block inside a run block overrides or supplies values scoped to that one run, while the top-level variables block sets defaults shared across every run block in the file — this is exactly the storage-account module from earlier in this unit, being handed name = "acmeapplogs" and asserted to actually produce a resource with that name.
The command argument: plan versus apply
Section titled “The command argument: plan versus apply”Every run block has a command argument, and the choice between its two values, plan and apply, is the most consequential decision in a test file.
command = plan is fast and creates no real resources. Terraform builds a plan, and your assert blocks check the planned values — this is exactly what the example above does, since name comes straight from a variable and is known before anything is ever applied. Reach for command = plan for the majority of test cases: anything you can verify from configuration alone, like “does this input produce this expected attribute,” belongs here.
command = apply actually creates real resources in Azure, asserts against their real resulting attributes, and then tears everything down again once the test file finishes. This is genuine integration testing — it is the only way to assert on a value Azure computes for you, such as a generated endpoint or resource ID, because that value simply does not exist until something has actually been applied.
variables { name = "acmeapplogs" resource_group_name = "rg-app-logs"}
run "creates_storage_account_with_working_endpoint" { command = apply
assert { condition = output.primary_blob_endpoint != "" error_message = "Expected a non-empty primary_blob_endpoint after apply" }}command = apply is slower and briefly costs real cloud resources, since Terraform genuinely provisions and then destroys them as part of the test run. Use it deliberately, for the assertions that actually need a live Azure-computed value — not as the default for every run block in the file.
flowchart TB
subgraph rung1["terraform validate"]
v1["HCL syntax and internal consistency"] --> v2["never talks to Azure"]
end
subgraph rung2["terraform plan"]
p1["talks to the real Azure API"] --> p2["shows the diff, asserts nothing about it"]
end
subgraph rung3["terraform test"]
t1["run blocks with assert"] --> t2["condition and error_message enforce expected behavior"]
end
rung1 -->|catches malformed config| rung2
rung2 -->|catches provider-side errors| rung3
rung3 -->|catches wrong results| done["confidence the module actually works"]