Terraform
terraform test: Unit-Test Modules Without a Second CI Language
How to write terraform test files that catch module regressions in plan — run blocks, assert, mock providers, and when you still need Terratest.
2026-09-06 · 3 min read
terraform test (1.6+) runs HCL tests against a module. You do not need Go, a cloud account, or a 400-line Terratest file to check that “private subnets are planned when enable_private is true.”
Use it for module contracts. Use Terratest or a real staging apply for “this VPC actually pings.”
File layout
modules/network/
main.tf
variables.tf
outputs.tf
tests/
private_subnets.tftest.hcl
no_public_by_default.tftest.hcl
From the module directory:
terraform test
terraform test -verbose
CI should run terraform test on every module PR, before any root-level plan.
A plan-only test
# tests/private_subnets.tftest.hcl
variables {
cidr_block = "10.20.0.0/16"
enable_private_subnets = true
azs = ["us-east-1a", "us-east-1b"]
}
run "plans_private_subnets" {
command = plan
assert {
condition = length(aws_subnet.private) == 2
error_message = "Expected two private subnets, got ${length(aws_subnet.private)}."
}
assert {
condition = alltrue([for s in aws_subnet.private : s.map_public_ip_on_launch == false])
error_message = "Private subnets must not auto-assign public IPs."
}
}
command = plan never calls Create. It still needs provider init, so pin providers in the module (or a terraform block in the test).
Apply tests and mocks
command = apply is slower and needs credentials unless you mock:
mock_provider "aws" {
alias = "fake"
}
run "with_mock" {
command = apply
providers = {
aws = aws.fake
}
assert {
condition = output.vpc_id != ""
error_message = "vpc_id should be set after apply."
}
}
Mocks are good for output wiring and count / for_each branches. They will not catch an invalid AWS API argument. Keep one nightly apply-to-a-scratch-account job for that.
Testing expected failures
A module should reject a /32 “VPC”:
run "rejects_tiny_cidr" {
command = plan
variables {
cidr_block = "10.0.0.1/32"
}
expect_failures = [
var.cidr_block,
]
}
expect_failures is for validation {} blocks and precondition. If you do not list the address, a failed validation fails the test — which is what you want when you did not expect it.
Root modules vs child modules
Test the reusable module, not the whole company root. Roots have remote state, 12 providers, and take 8 minutes to plan. If a root needs a test, extract the risky bit (IRSA trust policy, listener rules) into a module and test that.
What not to assert
- Exact random IDs or
arn:...strings that change every apply - Provider-computed values you do not control
- “Resource count == 47” — brittle. Assert the invariant (“at least one private route table”).
CI sketch
- name: Terraform tests
working-directory: modules/network
run: |
terraform init -backend=false
terraform test
No backend. No cloud keys. Fail the PR if an assert fails.
Pair with plan-file CI for roots and for_each vs count so a green module test is not undone by a reckless root apply.
Common pitfalls
- Applying tests in CI against prod credentials. Use
planor mocks by default. - Tests that only
init. An emptyrunblock is not a test. - Asserting
length(resource) == nafter a feature flag without a second run that covers the flag off. - Putting tests only in the root. Reviewers will skip them when the root takes 10 minutes.
- Terratest or terraform test. You want both layers; they catch different bugs.
When to reach for Terratest
- You need SSH/HTTP against a real instance
- You are testing a provisioner or a cloud-side eventual consistency
- The invariant is “DNS resolves after 60s”
That is integration. Keep it nightly. Keep terraform test on every commit.
