The problem
Your network module is called by five teams. You change how subnets are built, it plans fine on your laptop with your inputs, you tag a release - and two teams get broken plans because their inputs take a path you never tried.
"It planned on my laptop" is not a test. Since Terraform 1.6 there is a native test framework, terraform test: you write test cases in HCL next to the module, and a CI job runs them on every change.
What you need to know already:
- modules, inputs, outputs (13.34) and module design (13.41)
validationblocks, preconditions andcheckblocks (12.8, 12.10)- values that are
(known after apply)(12.24) - exit codes: 0 means success (1.7)
Where tests live
modules/network/
main.tf
variables.tf
outputs.tf
tests/
network.tftest.hcl
Test files end in .tftest.hcl and live in the module directory or in a tests/ directory under it. terraform test runs every one of them.
Anatomy of a test file
# tests/network.tftest.hcl
variables {
name = "vnet-test"
address_space = ["10.99.0.0/16"]
subnets = {
app = { cidr = "10.99.1.0/24" }
data = { cidr = "10.99.2.0/24" }
}
}
run "creates_one_subnet_per_entry" {
command = plan
assert {
condition = length(azurerm_subnet.this) == 2
error_message = "expected one subnet per entry in var.subnets"
}
assert {
condition = azurerm_subnet.this["app"].address_prefixes[0] == "10.99.1.0/24"
error_message = "the app subnet got the wrong CIDR"
}
}
run "rejects_an_invalid_cidr" {
command = plan
variables {
subnets = {
app = { cidr = "10.99.1.0/33" }
}
}
expect_failures = [var.subnets]
}
The file in words:
- The top
variablesblock gives the module inputs, as a caller would. - The first
runplans the module and checks two things: there are two subnets, andappgot the right range. - The second
runpasses a range that cannot exist (/33) and expects the module's validation onvar.subnetsto reject it. If the module accepted it, the test would fail.
The pieces:
variables { } values for the module's input variables, for every run in the file
run "name" { } one test step; runs in order, sharing state within the file
command = plan only plan - fast, creates nothing
command = apply (the default) really creates the resources; destroyed at the end
variables { } overrides for this run only
assert { } condition + error_message; every assert in a run is checked
expect_failures the run PASSES only if these things fail
(a variable's validation, a precondition, a check block)
module { source } run against a different module (e.g. an example that calls this one)
Inside an assert you can refer to everything in the module under test: resources (azurerm_subnet.this["app"]), outputs (output.subnet_ids), variables (var.name) - and outputs of earlier runs (run.setup.some_output).
Running it
terraform init first (to install providers), then terraform test:
# in the network module with tests/network.tftest.hcl (the testing mission)
terraform init
terraform test
tests/network.tftest.hcl... in progress
run "creates_one_subnet_per_entry"... pass
run "rejects_an_invalid_cidr"... pass
tests/network.tftest.hcl... pass
Success! 2 passed, 0 failed.
One line per run with pass or fail, one per file, and a total.
A failure prints the assertion's message in the usual error frame:
run "creates_one_subnet_per_entry"... fail
╷
│ Error: Test assertion failed
│
│ on tests/network.tftest.hcl line 13, in run "creates_one_subnet_per_entry":
│ 13: condition = length(azurerm_subnet.this) == 2
│
│ expected one subnet per entry in var.subnets
╵
tests/network.tftest.hcl... fail
Failure! 1 passed, 1 failed.
and the command exits non-zero, which is what makes a CI step fail.
Useful flags:
-filter=tests/network.tftest.hclruns only that file;-verboseprints the plan (or state) of each run.
plan runs vs apply runs
A command = plan run is fast and free, but it can only check values known at plan time. Anything the provider computes - an id, an IP address, a generated name - is still unknown:
│ Error: Unknown condition value
│
│ Condition expression could not be evaluated at this time. This means you have
│ 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.
│ Either remove this value from your condition, or execute an `apply` command
│ from this `run` block.
A command = apply run really creates the infrastructure in the Azure account you are signed in to. It checks its asserts against real values, and at the end of the file destroys everything the file created ("tearing down").
That is the only way to test things like "the private endpoint (13.4) really gets an IP". It also costs money and time, and needs manual cleanup if a run is killed half-way. (In the lab, apply runs use the engine's computed values and create nothing in the simulated cloud - simulator.)
Terraform 1.7 added mocking: mock_provider "azurerm" {} in a test file makes a fake provider that invents the computed values, so apply-style tests run without any Azure account. The lab's test runner does not have it (simulator), but know that it exists.
What to test
Good module tests check behaviour callers rely on:
- the resources a given input produces (how many, names, keys);
- that defaults are safe (no public access, TLS minimum, logging on);
- that invalid inputs are rejected (
expect_failureson each validation); - that outputs have the documented shape.
Not worth testing: that the provider does what its docs say (the azurerm provider has its own tests), or that a fixed value equals itself.
Where tests sit in the pipeline
fmt -check -> validate -> lint -> terraform test -> (for a module: release a tag)
fmt -check and validate you know (12.24). A linter is a tool that flags suspicious code that is still valid (for Terraform: tflint).
For modules, terraform test in CI on every PR is the gate before a new version is tagged. For root configurations, the plan itself is the test, and check blocks cover the rest.
Later (Ch 14): the full pre-merge pipeline, with tflint and security scanning.
What you can now do:
- write a
.tftest.hclfile withrun,assertandexpect_failures - choose between plan runs and apply runs
- decide what is worth testing in a module