Skip to content

Testing Modules

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.

Terminal window
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.

Terminal window
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.

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:

modules/logging/main.tf
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
}
modules/logging/variables.tf
variable "app_name" {
type = string
}
variable "location" {
type = string
}
variable "resource_group_name" {
type = string
}
variable "retention_days" {
type = number
}
main.tf
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:

tests/storage_account.tftest.hcl
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 test
tests/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"... pass
tests/storage_account.tftest.hcl... tearing down
tests/storage_account.tftest.hcl... pass
Success! 5 passed, 0 failed.

Four mechanisms, each proven by one of those five runs:

  • override_module replaces an entire child module’s outputs with fixed values, without executing that module’s own resources at all — proven above: module.logging.workspace_id resolved 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_resource replaces 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 overriding primary_blob_endpoint under command = plan without override_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.” Adding override_during = plan to the override_resource block is what makes the overridden value available for a plan-time assertion — proven by the pass above once that argument was added.
  • expect_failures proves a variable validation block actually rejects bad input — proven above: passing storage_account_name = "ab" (2 characters) to a run that names var.storage_account_name in expect_failures passes the test precisely because the validation failed as expected; if the validation had not fired, this run would fail instead.
  • command = apply with mock_provider still runs a real, full apply-and-destroy cycle — just against mock-generated resources instead of real Azure ones. Proven above: it passed identically to the plan-only runs, and this is precisely how a terraform test file can meaningfully test command = apply behavior for a resource like azurerm_storage_account that this course otherwise cannot plan or apply at 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"]
Three levels of confidence, each catching a different class of mistake

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.

ChoiceBenefitCost
mock_provider + command = applyFull apply-and-destroy cycle with zero real credentials or infrastructure — proven above, the entire test suite ran against azurerm_storage_account with no subscriptionMock-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 = planAssert on a specific overridden attribute before paying for a real apply — proven aboveEasy to reach for plan by default and get a real “Unknown condition value” error, proven above, if this argument is left off
override_moduleIsolates the module under test from a dependency’s real behavior entirely — proven above, the child module’s own resources never even executeThe dependency’s own real behavior is never tested by this run; it needs its own separate test
expect_failuresProves a validation block actually rejects bad input, not just that it compiles — proven aboveThe run “passing” means the validation failed as expected — reading the test output correctly takes a moment to get used to
  • “override_resource’s values always take effect for a command = plan assertion.” Proven above by the real failure encountered while writing this proof: without override_during = plan, the overridden value stays unknown until apply, and the assertion fails with “Unknown condition value … executed a run block with command = plan.”
  • “azurerm resources genuinely cannot be tested with command = apply in this course, since plan needs a real subscription.” Proven above: with mock_provider, apply runs a full create-and-destroy cycle against mock-generated resources — genuinely exercising the apply code path for azurerm_storage_account without ever calling Azure.
  • “override_module still executes the child module’s own resources, just with different inputs.” Proven above: the overridden module.logging.workspace_id returned the literal fake string, not something derived from azurerm_log_analytics_workspace.this — the child module’s resources are replaced entirely, not re-planned with different values.
  • “A run block using expect_failures is 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.

When reviewing generated .tftest.hcl files, check for:

  • override_resource overriding a computed attribute under command = plan with no override_during = plan — proven above, this fails with “Unknown condition value,” not silently passing with the wrong value.
  • command = apply used throughout an entire test suite with the real azurerm provider (no mock_provider), when most of the assertions only need command = plan — proven above, plan is 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_provider and no real subscription configured anywhere — such a file cannot pass command = apply at all; check whether mock_provider was meant to be there.
  • expect_failures naming the wrong checkable object (asserting var.storage_account_name failed 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 run blocks in one file where an earlier command = apply run’s real created state changes what a later command = plan run sees — proven implicitly above by why the apply run 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.

What can terraform validate catch, and what can it never catch
Why does passing terraform plan cleanly not guarantee a module actually works as intended
In a .tftest.hcl run block, what is the practical difference between command = plan and command = apply
This real error occurred while overriding a computed attribute under command = plan. What fixes it, proven above?
$ terraform test
...
Error: Unknown condition value
...
azurerm_storage_account.this.primary_blob_endpoint is a string

Condition expression could not be evaluated at this time. ... 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.
Given that azurerm needs a real subscription for plan/apply elsewhere in this course, how does this lesson prove command = apply behavior for azurerm_storage_account with zero credentials?
Why would a test need command = apply instead of command = plan to assert on a value like primary_blob_endpoint