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.
# Illustrative: tests/storage_account_name.tftest.hcl (targets the storage-account module from writing-a-reusable-module.mdx)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.
# Illustrative: tests/storage_account_endpoint.tftest.hcl (targets the storage-account module from writing-a-reusable-module.mdx)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.
mock_provider, override_resource, override_module, and expect_failures
Section titled “mock_provider, override_resource, override_module, and expect_failures”Every proof so far in this lesson has a real limitation: earlier lessons established that azurerm’s plan needs a real subscription — this course validates azurerm rather than planning it against real Azure. Terraform 1.7’s mocking feature changes that specifically for terraform test. mock_provider replaces the real azurerm provider entirely — every resource it manages gets provider-generated mock values for any attribute your config does not set explicitly, so command = apply inside a test can run start to finish with zero real API calls and zero real credentials, azurerm included. This is proven fully below with a real, complete module and a real .tftest.hcl file.
The module under test — a small azurerm_storage_account with a nested logging module and a variable validation:
resource "azurerm_log_analytics_workspace" "this" { name = "law-${var.app_name}" location = var.location resource_group_name = var.resource_group_name retention_in_days = var.retention_days}
output "workspace_id" { value = azurerm_log_analytics_workspace.this.id}variable "app_name" { type = string}
variable "location" { type = string}
variable "resource_group_name" { type = string}
variable "retention_days" { type = number}terraform { required_providers { azurerm = { source = "hashicorp/azurerm" version = "~> 5.0" } }}
variable "storage_account_name" { type = string validation { condition = length(var.storage_account_name) >= 3 error_message = "storage_account_name must be at least 3 characters." }}
variable "app_name" { type = string default = "web"}
resource "azurerm_storage_account" "this" { name = var.storage_account_name resource_group_name = "acme-rg" location = "eastus" account_tier = "Standard" account_replication_type = "LRS"}
module "logging" { source = "./modules/logging" app_name = var.app_name location = "eastus" resource_group_name = "acme-rg" retention_days = 30}The test file — mock_provider "azurerm" {} at the top applies to every run block in the file that does not declare its own:
mock_provider "azurerm" {}
variables { storage_account_name = "acmeapplogstest"}
run "name_matches_input" { command = plan
assert { condition = azurerm_storage_account.this.name == var.storage_account_name error_message = "Storage account name did not match input" }}
run "override_logging_module_output" { command = plan
override_module { target = module.logging outputs = { workspace_id = "/subscriptions/00000000/resourceGroups/acme-rg/providers/Microsoft.OperationalInsights/workspaces/fake" } }
assert { condition = module.logging.workspace_id == "/subscriptions/00000000/resourceGroups/acme-rg/providers/Microsoft.OperationalInsights/workspaces/fake" error_message = "override_module did not replace the logging module output" }}
run "override_storage_account_resource" { command = plan
override_resource { target = azurerm_storage_account.this override_during = plan values = { primary_blob_endpoint = "https://overridden.blob.core.windows.net/" } }
assert { condition = azurerm_storage_account.this.primary_blob_endpoint == "https://overridden.blob.core.windows.net/" error_message = "override_resource did not replace the computed primary_blob_endpoint attribute" }}
run "name_too_short_fails_validation" { command = plan
variables { storage_account_name = "ab" }
expect_failures = [ var.storage_account_name, ]}
run "storage_account_is_created_with_correct_name" { command = apply
assert { condition = azurerm_storage_account.this.name == var.storage_account_name error_message = "Real storage account name did not match input" }}Real terraform test output — every run genuinely executed, zero Azure credentials configured anywhere, despite azurerm_storage_account being a resource this course cannot otherwise plan or apply at all:
$ terraform testtests/storage_account.tftest.hcl... in progress run "name_matches_input"... pass run "override_logging_module_output"... pass run "override_storage_account_resource"... pass run "name_too_short_fails_validation"... pass run "storage_account_is_created_with_correct_name"... passtests/storage_account.tftest.hcl... tearing downtests/storage_account.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.workspace_idresolved to the literal fake ID, 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: the first attempt at this assertion (not shown — see the misconception below) tried overridingprimary_blob_endpointundercommand = 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: passingstorage_account_name = "ab"(2 characters) to arunthat namesvar.storage_account_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 Azure ones. Proven above: it passed identically to theplan-only runs, and this is precisely how aterraform testfile can meaningfully testcommand = applybehavior for a resource likeazurerm_storage_accountthat this course otherwise cannotplanorapplyat all without a real subscription.
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"] 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?” — a question this course cannot even fully ask of azurerm without a real subscription. 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, which for azurerm in this course is the difference between “cannot test at all” and “can genuinely test the apply code path.” 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 against azurerm_storage_account with no subscription | Mock-generated attribute values are not real provider behavior; a real Azure API rejecting a valid-looking argument combination still needs a genuine apply against a real subscription 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.” - “
azurermresources genuinely cannot be tested withcommand = applyin this course, sinceplanneeds a real subscription.” Proven above: withmock_provider,applyruns a full create-and-destroy cycle against mock-generated resources — genuinely exercising the apply code path forazurerm_storage_accountwithout ever calling Azure. - “
override_modulestill executes the child module’s own resources, just with different inputs.” Proven above: the overriddenmodule.logging.workspace_idreturned the literal fake string, not something derived fromazurerm_log_analytics_workspace.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 the real azurerm 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 azurerm resource attributes with no
mock_providerand no real subscription configured anywhere — such a file cannot passcommand = applyat all; check whethermock_providerwas meant to be there. expect_failuresnaming the wrong checkable object (assertingvar.storage_account_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_blob_endpoint" {command = plan
override_resource { target = azurerm_storage_account.this values = { primary_blob_endpoint = "https://overridden.blob.core.windows.net/" }}
assert { condition = azurerm_storage_account.this.primary_blob_endpoint == "https://overridden.blob.core.windows.net/" error_message = "override did not take effect"}}Show the answer
Proven above with the real error this produces: primary_blob_endpoint is a computed attribute, unknown until azurerm_storage_account.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.