Testing Terraform
terraform test, Terratest, validate.
A blueprint can be drawn perfectly and still describe a room with no door. Terraform code fails the same way. The syntax parses, terraform apply finishes without a single error, and the thing you just built has a security group (a virtual firewall wrapped around a machine) that lets in the entire internet. Nothing in there is a typo. Testing infrastructure code is how you catch the gap between valid and correct, and every tool for the job trades the same two things against each other: what the check costs you to run, and how much it actually proves. The defaults that hurt you are all perfectly legal Terraform. A wide-open ingress rule, an unencrypted disk, a bucket that quietly allows public reads. Only a check that knows what your team considers safe will ever flag them.
The Two Rungs That Cost You Nothing
terraform fmt and terraform validate are the spellcheck and the grammar check of your codebase. fmt rewrites your files into Terraform's house style. In CI (continuous integration, the robot that runs your checks on every push) you add -check, which tells it to change nothing and fail the build instead. Sounds cosmetic. There is a security payoff hiding in it: when formatting never drifts, a diff shows only changes in meaning, so a widened CIDR block (Classless Inter-Domain Routing, the 10.0.0.0/8 way of writing a whole range of IP addresses) cannot hide inside a fifty-line reindentation that a tired reviewer scrolls past.
terraform fmt -check -recursive -diffecho "exit=$?"
modules/network/main.tf--- old/modules/network/main.tf+++ new/modules/network/main.tf@@ -10,7 +10,7 @@type = "ingress"from_port = 443to_port = 443- protocol = "tcp"- cidr_blocks = ["203.0.113.0/24"]+ protocol = "tcp"+ cidr_blocks = ["203.0.113.0/24"]security_group_id = aws_security_group.app.id}exit=3
terraform validate goes a level deeper. It reads the whole configuration and checks that it hangs together: every variable you reference is declared, every argument gets the type it expects, every module input actually exists. It makes no cloud API calls and needs no credentials, so nobody can run up a bill with it. It does need terraform init first, because checking a resource means reading that provider's schema, which is the plugin's own description of which arguments exist and what type each one takes. That is also the catch worth knowing. init downloads whichever provider plugins the configuration asks for, and validate starts them, so a stranger's branch still gets a binary of its own choosing running on your machine. No credentials is not the same as no risk.
terraform init -backend=false -input=falseterraform validate
Initializing provider plugins...- Reusing previous version of hashicorp/aws from the dependency lock file- Using previously-installed hashicorp/aws v5.62.0Terraform has been successfully initialized!╷│ Error: Reference to undeclared input variable││ on network.tf line 12, in resource "aws_security_group_rule" "office":│ 12: cidr_blocks = [var.offce_cidr]││ An input variable with the name "offce_cidr" has not been declared. This│ variable can be declared with a variable "offce_cidr" {} block.╵
Those two rungs catch the class of mistake that has exactly one right answer. Neither will ever tell you whether var.office_cidr should have been 0.0.0.0/0, which is shorthand for every address on the internet. Both are valid strings. Which one is acceptable is a house rule, and house rules need assertions.
Assertions That Live Next to the Module
Terraform has shipped its own test runner since version 1.6. You write files ending in .tftest.hcl (HCL is HashiCorp Configuration Language, the same syntax your .tf files already use), and Terraform picks them up from the current directory and from a tests/ subdirectory. Each file is a short script of run blocks. Think of a run block as a recipe card with the taste test written on the back: it sets out the ingredients, cooks, then checks the result against what you said it should be.
command = plan is the setting you want for most security checks. Terraform works out what it would create and lets you assert against that without creating anything, so the run takes seconds and costs nothing. command = apply, which is what you get if you leave command out, builds the resources for real. That is the only way to read an attribute that does not exist until something is running, like the address a load balancer actually got handed. Runs inside one file go top to bottom and share state (Terraform's written record of what it has built), so an early run can stand up a network and a later run can read its outputs as run.<name>.<output>. When the file finishes, Terraform destroys everything that file created.
variables {name = "payments"}run "secure_defaults" {command = planvariables {allowed_cidrs = ["10.20.0.0/16"]}assert {condition = alltrue([for r in aws_security_group.app.ingress : !contains(r.cidr_blocks, "0.0.0.0/0")])error_message = "security group must never allow ingress from 0.0.0.0/0"}assert {condition = aws_s3_bucket_public_access_block.logs.block_public_aclserror_message = "log bucket must block public ACLs"}}
terraform test
tests/hardening.tftest.hcl... in progressrun "secure_defaults"... passtests/hardening.tftest.hcl... tearing downtests/hardening.tftest.hcl... passSuccess! 1 passed, 0 failed.
Now somebody opens a temporary hole for a vendor demo on Friday and never closes it. The pull request (the change a teammate proposes and asks you to merge) looks small and reasonable. Here is what the same command says.
terraform test
tests/hardening.tftest.hcl... in progressrun "secure_defaults"... fail╷│ Error: Test assertion failed││ on tests/hardening.tftest.hcl line 13, in run "secure_defaults":│ 13: condition = alltrue([│ 14: for r in aws_security_group.app.ingress : !contains(r.cidr_blocks, "0.0.0.0/0")│ 15: ])│ ├────────────────│ │ aws_security_group.app.ingress is set of object with 2 elements││ security group must never allow ingress from 0.0.0.0/0╵tests/hardening.tftest.hcl... tearing downtests/hardening.tftest.hcl... failFailure! 0 passed, 1 failed.
That message is the product. A reviewer skimming a forty-file diff can miss one extra entry in a cidr_blocks list. The test cannot miss it, it runs in seconds without touching an account, and it repeats the rule your team agreed on, in plain English, at the exact moment someone breaks it.
Prove the Guard Actually Fires
Most test suites only check that the good path works. A smoke alarm you have never held a match under is decoration. The check that earns its keep in security work is the opposite one: hand the module something you consider dangerous and prove it refuses.
You build that from two halves. In the module, a validation block on a variable rejects bad input with an error message you wrote yourself. In the test, expect_failures says: this run is supposed to fail, and here is the thing that should raise the complaint. Inverting the check is what makes it durable.
variable "allowed_cidrs" {type = list(string)description = "Source ranges permitted to reach the app"validation {condition = !contains(var.allowed_cidrs, "0.0.0.0/0")error_message = "The allowed_cidrs list must not contain 0.0.0.0/0."}}
run "refuses_world_open_cidr" {command = planvariables {allowed_cidrs = ["0.0.0.0/0"]}# this run passes ONLY if var.allowed_cidrs raises an errorexpect_failures = [var.allowed_cidrs]}
Six months later someone finds that validation block inconvenient during an incident and deletes it. Watch what the test does.
terraform test -filter=tests/rejects_open_cidr.tftest.hcl
tests/rejects_open_cidr.tftest.hcl... in progressrun "refuses_world_open_cidr"... fail╷│ Error: Missing expected failure││ on tests/rejects_open_cidr.tftest.hcl line 9, in run "refuses_world_open_cidr":│ 9: expect_failures = [var.allowed_cidrs]││ The checkable object, var.allowed_cidrs, was expected to report an error but│ did not.╵tests/rejects_open_cidr.tftest.hcl... tearing downtests/rejects_open_cidr.tftest.hcl... failFailure! 0 passed, 1 failed.
The test is guarding the guard. Remove the validation and the run stops failing, which makes the test fail, which blocks the merge. That loop is worth wrapping around every safety default your module ships.
Mocked Providers: Assertions With No Cloud Account
There is a catch in everything so far. A plan still talks to the provider, and most real configurations need credentials before they can produce one: a data source (a read-only lookup, as opposed to something Terraform creates) that fetches the newest AMI (Amazon Machine Image, the disk template a virtual machine boots from), a call that asks the cloud who you are logged in as, a read of state parked in a shared bucket. Handing cloud credentials to a job that runs code from an incoming pull request is the exact thing you want to avoid.
Terraform 1.7 added mock_provider, a crash-test dummy shaped like your real provider. It answers every call with invented values instead of reaching out, so a plan, or even an apply, finishes with no account, no bill, and no network traffic. You pin the handful of attributes your assertions care about and let Terraform make up the rest. You still run terraform init, because Terraform needs the provider's schema to make sense of your resources. What you skip is every call the provider would have made.
mock_provider "aws" {mock_data "aws_ami" {defaults = {id = "ami-0c55b159cbfafe1f0"architecture = "x86_64"}}}run "encryption_on_by_default" {command = planassert {condition = aws_ebs_volume.data.encryptederror_message = "data volume must be encrypted at rest"}assert {condition = aws_ebs_volume.data.kms_key_id == var.cmk_arnerror_message = "data volume must use our own key, not the AWS-managed default"}}
One trap comes with the dummy. A mock answers with whatever it feels like for anything your configuration does not set, so an assertion aimed at an invented value passes while proving nothing at all. Test what your code decides: encrypted set to true, a key ARN (Amazon Resource Name, AWS's long unique identifier for a thing) you passed in yourself. That is why the second assertion compares against var.cmk_arn instead of checking that kms_key_id is merely non-empty, which a random mocked string would satisfy every time. EBS is Elastic Block Store, the virtual hard disks an EC2 machine boots from and writes to, and KMS is Key Management Service, where AWS keeps encryption keys.
Now the whole assertion suite is safe to run on every pull request, including one from a fork (a stranger's own copy of your repository), because there is nothing to steal and nothing to bill. Keep the credentialed runs in a separate job that only a maintainer can start.
The Top Rung: Build It and Poke It
Everything above reasons about a plan, and a plan is a description of a building, not the building. A security group can be written correctly and still be bypassed by a route somebody added last year. A TLS (Transport Layer Security, the encryption behind the padlock in HTTPS) listener can exist and still serve the wrong certificate. The only way to know is to put the thing up and pull on the door handle.
Terratest is a library from Gruntwork that does exactly that. It is written in Go, a compiled programming language, and you do not need to know Go well to copy the pattern. Your test is an ordinary Go test function. It runs init and apply against a real example directory, reads the outputs, makes real requests against the running thing, then destroys it on the way out. defer is Go's way of saying run this when the function ends, no matter how it ends, so queueing the teardown before the apply means cleanup still happens when an assertion blows up halfway through.
package testimport ("net/http""strings""testing""time"http_helper "github.com/gruntwork-io/terratest/modules/http-helper""github.com/gruntwork-io/terratest/modules/random""github.com/gruntwork-io/terratest/modules/terraform""github.com/stretchr/testify/assert""github.com/stretchr/testify/require")func TestWebModuleRedirectsPlainHTTP(t *testing.T) {opts := &terraform.Options{TerraformDir: "../examples/web",Vars: map[string]interface{}{"name_prefix": "tt-" + random.UniqueId(),},}defer terraform.Destroy(t, opts) // queued BEFORE apply, so it runs even on failureterraform.InitAndApply(t, opts)url := terraform.Output(t, opts, "https_url")http_helper.HttpGetWithRetry(t, url, nil, 200, "ok", 30, 10*time.Second)// Go's default client FOLLOWS redirects, which would hide the bug we care about.noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {return http.ErrUseLastResponse},}resp, err := noFollow.Get(strings.Replace(url, "https://", "http://", 1))require.NoError(t, err)defer resp.Body.Close()assert.Equal(t, 301, resp.StatusCode) // plain HTTP must never serve contentassert.True(t, strings.HasPrefix(resp.Header.Get("Location"), "https://"))}
go test -v -timeout 45m ./test/
=== RUN TestWebModuleRedirectsPlainHTTPTestWebModuleRedirectsPlainHTTP 2026-07-21T09:12:04Z logger.go:66: Running command terraform with args [init -upgrade=false]TestWebModuleRedirectsPlainHTTP 2026-07-21T09:19:41Z logger.go:66: Apply complete! Resources: 14 added, 0 changed, 0 destroyed.TestWebModuleRedirectsPlainHTTP 2026-07-21T09:19:47Z http_helper.go:44: Making an HTTP GET call to URL https://tt-8fk2j1.sandbox.example.comTestWebModuleRedirectsPlainHTTP 2026-07-21T09:22:14Z logger.go:66: Destroy complete! Resources: 14 destroyed.--- PASS: TestWebModuleRedirectsPlainHTTP (612.44s)PASSok github.com/acme/infra/test 612.83s
Which Rung Runs When
Every rung reports an exit code, the single number a program hands back when it finishes, and that number is all a pipeline needs. terraform fmt -check returns 3 when a file is unformatted and 0 when everything is clean. validate and test return 0 on success and 1 on failure. So the per-commit job is a short script that stops at the first non-zero result, and it runs with no cloud credentials anywhere in its environment.
#!/usr/bin/env bashset -euo pipefail# No AWS_* variables are exported into this job. Nothing here needs them.terraform fmt -check -recursive # exit 3 if any file is unformattedterraform init -backend=false -input=falseterraform validate # exit 1 on a bad reference or typeterraform test -filter=tests/mocked.tftest.hcl# add -verbose to the test line when you need to see the plan behind a failing run
The credentialed rungs live somewhere else. Real-apply terraform test runs and Terratest suites belong on a nightly schedule and on the release branch, inside a dedicated sandbox account that has no path back to production: its own credentials, its own bill, and a permission boundary (a hard ceiling on what those credentials may ever do, no matter which policy someone attaches later) that makes reaching a production API impossible even when a test tries.
mock_provider test, the lesson asserts aws_ebs_volume.data.kms_key_id == var.cmk_arn rather than simply checking the key ID is non-empty. Why does that distinction matter? (KMS is Key Management Service, where AWS stores encryption keys.).tftest.hcl run uses command = plan and asserts on the real DNS (Domain Name System) name a load balancer was handed by the cloud. The run fails because that attribute is unknown at plan time. What is the correct fix, and what does it cost?If you are starting from zero, write one test and make it the negative one. Pick the default in your most-used module that would hurt most if it flipped: public access on a bucket, an unrestricted ingress rule, an unencrypted volume. Put a validation block behind it. Then write the expect_failures run that proves the block turns the dangerous value away. That file takes ten minutes, needs no cloud account, and goes red the day somebody deletes the guard.
Try this
Run terraform fmt -check -recursive -diff on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: testing a pull request means running its author's code. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.