Terraform · lesson 17 of 17
Beginner
7- 1 Terraform Fundamentals (IaC)
- 2 Terraform Workflow (init → validate → plan → apply)
- 3 Terraform State (tfstate, remote state, locking)
- 4 Terraform Variables and Outputs
- 5 Providers and Resources (the building blocks of Terraform)
- 6 Terraform Locals: Computed Values Without Repetition
- 7 Terraform Modules (reuse, composition, and clean structure)
Intermediate
7- 8 Terraform count and for_each (repeat resources safely)
- 9 The lifecycle Block: prevent_destroy, create_before_destroy, ignore_changes
- 10 Data Sources and Dependencies (read existing infrastructure)
- 11 sensitive = true: What It Hides, and What It Doesn't
- 12 Remote State Safety (locking, drift, and recovery)
- 13 Workspaces and Environments (dev/stage/prod patterns)
- 14 Terraform CI/CD with GitHub Actions (plan/apply)
Advanced
3Also part of Terraform
4Also part of: Software Testing
terraform test: Catching a Wrong Assertion, Not Just a Wrong Plan
Terraform's test runner checks real plans and applies from .tftest.hcl files. It catches a bug in the test, a deleted validation, and mocks a provider.
By SkillByExample · Updated
terraform plan tells you what a config would do. It doesn’t tell you whether that’s
correct, for example whether prod really gets more capacity than dev. terraform test
runs plans and applies from .tftest.hcl files and checks assertions after each one. Examples
use Terraform 1.16.5 and hashicorp/local 2.9.1.
A config with environment logic
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.9"
}
}
}
variable "environment" {
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of: dev, staging, prod."
}
}
locals {
max_connections = var.environment == "prod" ? 100 : 10
}
resource "local_file" "config" {
filename = "${path.module}/generated/app.conf"
content = "environment=${var.environment}\nmax_connections=${local.max_connections}\n"
}
output "max_connections" {
value = local.max_connections
}
output "config_path" {
value = local_file.config.filename
}
The rule under test is that prod gets 100 connections and everything else gets 10.
A test with a bug in the test
tests/config.tftest.hcl:
run "dev_defaults_are_low" {
command = plan
variables {
environment = "dev"
}
assert {
condition = output.max_connections == 10
error_message = "dev environment should cap connections at 10, got ${output.max_connections}"
}
}
run "prod_has_higher_ceiling" {
command = apply
variables {
environment = "prod"
}
assert {
condition = output.max_connections == 999
error_message = "prod environment should cap connections at 999, got ${output.max_connections}"
}
}
$ terraform test -no-color
tests/config.tftest.hcl... in progress
run "dev_defaults_are_low"... pass
run "prod_has_higher_ceiling"... fail
Error: Test assertion failed
on tests/config.tftest.hcl line 22, in run "prod_has_higher_ceiling":
22: condition = output.max_connections == 999
├────────────────
│ Diff:
│ --- actual
│ +++ expected
│ - 100
│ + 999
prod environment should cap connections at 999, got 100
tests/config.tftest.hcl... tearing down
tests/config.tftest.hcl... fail
Failure! 1 passed, 1 failed.
The config was right all along. The 999 is a typo in the test. A test failure doesn’t say
which side is wrong, only that the code and the test disagree. The diff shows actual (100)
against expected (999), and you decide which one to fix. Here it’s the test.
Fixing the assertion, not the config
Change 999 to 100 in both places:
$ terraform test -no-color
tests/config.tftest.hcl... in progress
run "dev_defaults_are_low"... pass
run "prod_has_higher_ceiling"... pass
tests/config.tftest.hcl... tearing down
tests/config.tftest.hcl... pass
Success! 2 passed, 0 failed.
dev_defaults_are_low only planned, so nothing was created. prod_has_higher_ceiling applied:
it really wrote generated/app.conf, and “tearing down” deleted it at the end of the file.
Add -verbose to see what each run planned or applied:
$ terraform test -verbose -no-color
tests/config.tftest.hcl... in progress
run "dev_defaults_are_low"... pass
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# local_file.config will be created
+ resource "local_file" "config" {
+ content = <<-EOT
environment=dev
max_connections=10
EOT
+ content_base64sha256 = (known after apply)
+ content_base64sha512 = (known after apply)
+ content_md5 = (known after apply)
+ content_sha1 = (known after apply)
+ content_sha256 = (known after apply)
+ content_sha512 = (known after apply)
+ directory_permission = "0777"
+ file_permission = "0777"
+ filename = "./generated/app.conf"
+ id = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ config_path = "./generated/app.conf"
+ max_connections = 10
run "prod_has_higher_ceiling"... pass
# local_file.config:
resource "local_file" "config" {
content = <<-EOT
environment=prod
max_connections=100
EOT
content_base64sha256 = "CsjBNyL6GW+B+kOPmasOtv6UVZKVceTBxvU+ivIsqCk="
content_base64sha512 = "JYPCiU/jvpIqY4mTF4tlEuDZYL0P2Ro7K1XanT70+0DgsLHxqanXqiQEYtfWhA1uQtY49JKpkLrtv/LeplvGkA=="
content_md5 = "3c60c5af0dddc6dcb0026e7eeb387f8d"
content_sha1 = "a3d2990d696e1fc81f3e72d5a25ef0fef13bd3c8"
content_sha256 = "0ac8c13722fa196f81fa438f99ab0eb6fe9455929571e4c1c6f53e8af22ca829"
content_sha512 = "2583c2894fe3be922a638993178b6512e0d960bd0fd91a3b2b55da9d3ef4fb40e0b0b1f1a9a9d7aa240462d7d6840d6e42d638f492a990baedbff2dea65bc690"
directory_permission = "0777"
file_permission = "0777"
filename = "./generated/app.conf"
id = "a3d2990d696e1fc81f3e72d5a25ef0fef13bd3c8"
}
Outputs:
config_path = "./generated/app.conf"
max_connections = 100
tests/config.tftest.hcl... tearing down
tests/config.tftest.hcl... pass
Success! 2 passed, 0 failed.
The plan run prints a plan, and the apply run prints the resulting state, with real hashes, because a real file was written.
Testing that bad input is rejected
tests/validation.tftest.hcl:
run "rejects_unknown_environment" {
command = plan
variables {
environment = "qa"
}
expect_failures = [
var.environment,
]
}
$ terraform test -no-color
tests/config.tftest.hcl... in progress
run "dev_defaults_are_low"... pass
run "prod_has_higher_ceiling"... pass
tests/config.tftest.hcl... tearing down
tests/config.tftest.hcl... pass
tests/validation.tftest.hcl... in progress
run "rejects_unknown_environment"... pass
tests/validation.tftest.hcl... tearing down
tests/validation.tftest.hcl... pass
Success! 3 passed, 0 failed.
expect_failures reverses the logic: this run passes because the validation rule rejected
qa. The test now pins the validation rule in place. If someone loosens or deletes it, this
run turns red (Practice 3).
Mocking the provider
tests/mocked.tftest.hcl replaces the real local provider with fake responses:
mock_provider "local" {
mock_resource "local_file" {
defaults = {
id = "mock-file-id"
}
}
}
run "prod_apply_with_mocked_provider" {
command = apply
variables {
environment = "prod"
}
assert {
condition = local_file.config.id == "mock-file-id"
error_message = "expected the mocked id, got ${local_file.config.id}"
}
}
$ terraform test -filter=tests/mocked.tftest.hcl -verbose -no-color
tests/mocked.tftest.hcl... in progress
run "prod_apply_with_mocked_provider"... pass
# local_file.config:
resource "local_file" "config" {
content = <<-EOT
environment=prod
max_connections=100
EOT
content_base64sha256 = "k1dem4i9"
content_base64sha512 = "wcgds2t7"
content_md5 = "3dd292qi"
content_sha1 = "y7nnzi3i"
content_sha256 = "11matojv"
content_sha512 = "6bag87f1"
directory_permission = "gg381xsb"
file_permission = "s4f5hzhf"
filename = "./generated/app.conf"
id = "mock-file-id"
}
Outputs:
config_path = "./generated/app.conf"
max_connections = 100
tests/mocked.tftest.hcl... tearing down
tests/mocked.tftest.hcl... pass
Success! 1 passed, 0 failed.
This “apply” never called the provider. Values set in the config (content, filename) are
real. Computed values are made up: id came from the mock’s defaults, and every other
computed attribute is a random 8-character string that changes on each run. Even
directory_permission is random, because it’s optional and computed. With a cloud provider,
this is how you test module logic in CI without an account. The trade-off is that a mock
can’t tell you whether the real API would accept the values.
Practice
1. Change the validation test’s input to "staging", a valid value, and run the whole
suite.
Solution
$ terraform test -no-color
tests/config.tftest.hcl... in progress
run "dev_defaults_are_low"... pass
run "prod_has_higher_ceiling"... pass
tests/config.tftest.hcl... tearing down
tests/config.tftest.hcl... pass
tests/mocked.tftest.hcl... in progress
run "prod_apply_with_mocked_provider"... pass
tests/mocked.tftest.hcl... tearing down
tests/mocked.tftest.hcl... pass
tests/validation.tftest.hcl... in progress
run "rejects_unknown_environment"... fail
Error: Missing expected failure
on tests/validation.tftest.hcl line 9, in run "rejects_unknown_environment":
9: var.environment,
The checkable object, var.environment, was expected to report an error but
did not.
tests/validation.tftest.hcl... tearing down
tests/validation.tftest.hcl... fail
Failure! 3 passed, 1 failed.
expect_failures is as strict about a check that unexpectedly passes as assert is about a
false condition. Files run in alphabetical order, so mocked runs before validation.
2. In dev_defaults_are_low, add one assertion on output.config_path and another on
local_file.config.content_md5 != "". Which one fails, and why?
Solution
assert {
condition = output.config_path == "./generated/app.conf"
error_message = "unexpected config path ${output.config_path}"
}
assert {
condition = local_file.config.content_md5 != ""
error_message = "file should have a checksum"
}
$ terraform test -filter=tests/config.tftest.hcl -no-color
tests/config.tftest.hcl... in progress
run "dev_defaults_are_low"... fail
Error: Unknown condition value
on tests/config.tftest.hcl line 19, in run "dev_defaults_are_low":
19: condition = local_file.config.content_md5 != ""
├────────────────
│ local_file.config.content_md5 is a string
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. Alternatively, if there is an override for this value,
you can make it available during the plan phase by setting `override_during =
plan` in the `override_` block.
run "prod_has_higher_ceiling"... skip
tests/config.tftest.hcl... tearing down
tests/config.tftest.hcl... fail
Failure! 0 passed, 1 failed, 1 skipped.
The path is known at plan time, but the checksum only exists after the file is written. Change
the run to command = apply and both pass (Success! 2 passed, 0 failed.). Note that the
failure also caused the next run in the file to be skipped.
3. Delete the validation block from variable "environment" and run only the
validation test.
Solution
$ terraform test -filter=tests/validation.tftest.hcl -no-color
tests/validation.tftest.hcl... in progress
run "rejects_unknown_environment"... fail
Error: Missing expected failure
on tests/validation.tftest.hcl line 9, in run "rejects_unknown_environment":
9: var.environment,
The checkable object, var.environment, was expected to report an error but
did not.
tests/validation.tftest.hcl... tearing down
tests/validation.tftest.hcl... fail
Failure! 0 passed, 1 failed.
The error is identical to Practice 1, but the cause is different. Here the rule that would
reject qa is gone. In both cases the test catches the problem, which is why it’s worth
having.