CoursesTerraformTesting Terraform

Testing Terraform

terraform test, Terratest, validate.

Advanced12 min · lesson 12 of 15

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 testing ladder, cheapest rung first
1terraform fmt -check
style only, exit 3 when a file is off
2terraform validate
types and references, no cloud calls
3terraform test (mocked)
your rules, zero credentials
4terraform test (real apply)
builds resources, tears them down
5Terratest
build it, poke it, destroy it
The first three run on every push in seconds and need no cloud account. The last two build real resources, so they belong on a nightly run or a release branch, inside a throwaway account nobody depends on.

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.

terminal
terraform fmt -check -recursive -diff
echo "exit=$?"
output
modules/network/main.tf
--- old/modules/network/main.tf
+++ new/modules/network/main.tf
@@ -10,7 +10,7 @@
type = "ingress"
from_port = 443
to_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.

terminal
terraform init -backend=false -input=false
terraform validate
output
Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v5.62.0
Terraform 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.

tests/hardening.tftest.hcl
variables {
name = "payments"
}
run "secure_defaults" {
command = plan
variables {
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_acls
error_message = "log bucket must block public ACLs"
}
}
terminal
terraform test
output
tests/hardening.tftest.hcl... in progress
run "secure_defaults"... pass
tests/hardening.tftest.hcl... tearing down
tests/hardening.tftest.hcl... pass
Success! 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.

terminal
terraform test
output
tests/hardening.tftest.hcl... in progress
run "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 down
tests/hardening.tftest.hcl... fail
Failure! 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.

variables.tf
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."
}
}
tests/rejects_open_cidr.tftest.hcl
run "refuses_world_open_cidr" {
command = plan
variables {
allowed_cidrs = ["0.0.0.0/0"]
}
# this run passes ONLY if var.allowed_cidrs raises an error
expect_failures = [var.allowed_cidrs]
}

Six months later someone finds that validation block inconvenient during an incident and deletes it. Watch what the test does.

terminal
terraform test -filter=tests/rejects_open_cidr.tftest.hcl
output
tests/rejects_open_cidr.tftest.hcl... in progress
run "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 down
tests/rejects_open_cidr.tftest.hcl... fail
Failure! 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.

tests/mocked.tftest.hcl
mock_provider "aws" {
mock_data "aws_ami" {
defaults = {
id = "ami-0c55b159cbfafe1f0"
architecture = "x86_64"
}
}
}
run "encryption_on_by_default" {
command = plan
assert {
condition = aws_ebs_volume.data.encrypted
error_message = "data volume must be encrypted at rest"
}
assert {
condition = aws_ebs_volume.data.kms_key_id == var.cmk_arn
error_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.

Testing a pull request means running its author's code
terraform plan, apply and test all execute provider plugins on your runner, and any local-exec provisioner in the configuration (a block that shells out to a command on whatever machine Terraform is running on) executes with everything that job can reach. A contributor can add one file to a fork and turn your test job into a script that reads the environment and posts your cloud token somewhere quiet. The classic way this happens is a GitHub Actions workflow triggered on pull_request_target, which runs with the base repository's secrets, and which then checks out the pull request's own branch. Use the plain pull_request trigger for outside contributions, run those with mocked providers and no credentials in the environment, and put every credentialed job behind a maintainer's approval.

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.

test/web_test.go
package test
import (
"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 failure
terraform.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 content
assert.True(t, strings.HasPrefix(resp.Header.Get("Location"), "https://"))
}
terminal
go test -v -timeout 45m ./test/
output
=== RUN TestWebModuleRedirectsPlainHTTP
TestWebModuleRedirectsPlainHTTP 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.com
TestWebModuleRedirectsPlainHTTP 2026-07-21T09:22:14Z logger.go:66: Destroy complete! Resources: 14 destroyed.
--- PASS: TestWebModuleRedirectsPlainHTTP (612.44s)
PASS
ok github.com/acme/infra/test 612.83s
Teardown is not guaranteed, so sweep the sandbox anyway
go test gives a package ten minutes by default. Infrastructure tests routinely need more, and when that deadline hits, Go kills the process from its own timer, so your deferred terraform.Destroy never runs and everything the test built stays alive. terraform test has the same class of problem: when a destroy fails it prints the resources it left behind in state and asks you to remove them by hand. Pass a generous -timeout (45m is not unusual), and run a scheduled sweeper that deletes anything in the sandbox account older than a day. Leftover test infrastructure keeps billing, and worse, it is an unowned security group or bucket sitting in a real account with nobody watching it.

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.

ci/checks.sh
#!/usr/bin/env bash
set -euo pipefail
# No AWS_* variables are exported into this job. Nothing here needs them.
terraform fmt -check -recursive # exit 3 if any file is unformatted
terraform init -backend=false -input=false
terraform validate # exit 1 on a bad reference or type
terraform 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.

Quick check
01A module has a validation block that rejects 0.0.0.0/0, and a test run with expect_failures = [var.allowed_cidrs] that passes today. A teammate deletes the validation block because it blocked an urgent change. What happens to that test?
Incorrect — expect_failures inverts the check, so a run that produces no error where one was expected counts as a failure.
Correct — That inversion is exactly what stops a safety check from being quietly removed.
Incorrect — Terraform compares the expectation against the diagnostics the run actually produced; a missing validation just means no error was raised.
Incorrect — Variable validation is evaluated during plan, and expect_failures is checked for plan runs too.
02In a 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.)
Correct — you can only trust an assertion about a value your own configuration decides, because the mock will happily fabricate everything else.
Incorrect — mocks create nothing real and cost no time; the point is what the assertion proves, not how fast it runs.
Incorrect — the attribute is readable at plan time; the problem is that a mocked value is meaningless, not that it is unavailable.
Incorrect — an assert condition is any boolean expression, including alltrue, contains and inequalities.
03A .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?
Incorrect — a mock would invent a fake DNS name, so the assertion would pass without ever checking the real assigned value.
Correct — attributes that exist only once a resource is running can be read only by a real apply, which is the expensive rung you reserve for release or nightly runs.
Incorrect — expect_failures is for inputs you want the module to reject, not for reading attributes that only exist after apply.
Incorrect — validate makes no cloud calls and never resolves runtime attributes; the value exists only once the resource is built.

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.

Related