Your first Terraform test
InitAndApply, then assert.
A restaurant inspector does not grade the recipe. They walk into the kitchen, open the fridge, push a thermometer into the chicken, and write down what they find. Terratest inspects infrastructure the same way. It runs your Terraform for real, against a real cloud account, reads back what came out, checks it against what you expected, then knocks the whole thing down. There is no mock provider. There is no dry run. terraform apply genuinely runs, resources genuinely appear, and your account is genuinely billed for every minute they exist.
Your first test is the smallest honest version of that loop. Apply a tiny example. Read one output. Compare it to what you expected. Destroy. Every other pattern in Terratest (retries, HTTP probes that fetch a web address and check what comes back, the Kubernetes and Helm helpers, staged tests you can resume) is a variation on this skeleton, which is a good reason to get the skeleton right the first time.
For security work the payoff is specific. A static scanner reads your HCL (HashiCorp Configuration Language, the .tf files Terraform consumes) and tells you the code declares a locked-down bucket. A Terratest run tells you the bucket that actually exists is locked down. Those are two different claims, and the gap between them is where incidents live: a resource dropped during a bad merge, a provider version that quietly changed a default, a guardrail that only applies when an optional variable happens to be set.
Build a Fixture You Can Afford to Lose
TerraformDir points the test at a small, self-contained example that lives inside your module repository, conventionally under examples/. Same idea as the little test loaf a baker bakes before committing the oven to a wedding cake: same dough, tiny, and nobody cries if it burns. Keep it cheap. No NAT gateways (Network Address Translation, the managed box that lets private machines reach the internet, billed by the hour), no managed databases, no three-node clusters. Keep it fast too, because you are going to run it hundreds of times.
One rule before you write a line of Go. Apply the example by hand and watch it succeed: cd examples/private-bucket && terraform init && terraform apply. If the HCL is broken, wrapping it in a Go test only puts a layer of Go between you and the error message.
terraform {required_version = ">= 1.5"required_providers {aws = {source = "hashicorp/aws"version = "~> 6.0"}}}provider "aws" {region = var.region}variable "region" {type = stringdefault = "eu-west-1"}variable "bucket_name" {type = stringdescription = "Globally unique name. The test generates a fresh one per run."}resource "aws_s3_bucket" "audit" {bucket = var.bucket_nameforce_destroy = true # fixture only: lets destroy delete a non-empty bucket}resource "aws_s3_bucket_public_access_block" "audit" {bucket = aws_s3_bucket.audit.idblock_public_acls = trueblock_public_policy = trueignore_public_acls = truerestrict_public_buckets = true}resource "aws_s3_bucket_server_side_encryption_configuration" "audit" {bucket = aws_s3_bucket.audit.idrule {apply_server_side_encryption_by_default {sse_algorithm = "AES256"}}}output "bucket_id" {value = aws_s3_bucket.audit.id}# One flat map. terraform.OutputMap stringifies whatever it finds, so the# tostring() calls are not strictly required, but they keep the JSON honest:# what the test compares is exactly what you see in the log.output "public_access" {value = {block_public_acls = tostring(aws_s3_bucket_public_access_block.audit.block_public_acls)block_public_policy = tostring(aws_s3_bucket_public_access_block.audit.block_public_policy)ignore_public_acls = tostring(aws_s3_bucket_public_access_block.audit.ignore_public_acls)restrict_public_buckets = tostring(aws_s3_bucket_public_access_block.audit.restrict_public_buckets)}}
Three choices in that file are deliberate. bucket_name is a variable so the test can hand in a fresh, globally unique name on every run, instead of two engineers fighting over one name in a namespace shared with the entire planet (S3, Simple Storage Service, gives every bucket on Earth a single flat namespace). force_destroy = true belongs in a throwaway fixture and nowhere near a production module, because it lets terraform destroy delete a bucket that still holds objects. And public_access exists purely to give the test something to read. It turns a security setting into a value an assertion can compare.
Be honest about what that output proves. Terraform computes outputs from its state file, so public_access reports what Terraform recorded when it built the resource. That catches the change you care about most, somebody removing or weakening the public access block. It is still one step removed from asking Amazon directly. Terratest's aws module can make the stronger call against the live API (application programming interface, the endpoint AWS exposes for programs rather than people), and later lessons use it. Start here, because output plus assertion is the shape you reuse for everything else.
The Test Is an Ordinary Go Test
Go's rules for tests are short. The file name ends in _test.go. The function name starts with Test and takes one argument, *testing.T. That t is the inspector's clipboard: every Terratest helper takes it as its first argument so it can record a failure on your behalf, which is why you are not checking a returned error after every single line.
package testimport ("strings""testing""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 TestPrivateBucketExample(t *testing.T) {// UniqueID() returns 6 random base62 characters (letters and digits).// S3 bucket names must be lowercase, hence the ToLower.bucketName := "sol-audit-" + strings.ToLower(random.UniqueID())opts := &terraform.Options{TerraformDir: "../examples/private-bucket",Vars: map[string]interface{}{"bucket_name": bucketName,"region": "eu-west-1",},EnvVars: map[string]string{"TF_IN_AUTOMATION": "1", // drops Terraform's "next steps" hints},}// Registered BEFORE anything exists, so teardown is queued no matter// how the rest of this function ends.defer terraform.Destroy(t, opts)terraform.InitAndApply(t, opts)// require: if the bucket is not the one we asked for, nothing below matters.require.Equal(t, bucketName, terraform.Output(t, opts, "bucket_id"))// assert: report every guardrail that is off, not only the first one.pab := terraform.OutputMap(t, opts, "public_access")assert.Equal(t, "true", pab["block_public_acls"])assert.Equal(t, "true", pab["block_public_policy"])assert.Equal(t, "true", pab["ignore_public_acls"])assert.Equal(t, "true", pab["restrict_public_buckets"])}
TerraformDir is a relative path, which is why it starts with ../. go test runs each package's test binary with the working directory set to that package's source folder, so the path resolves from the folder holding bucket_test.go. Vars is an ordinary Go map that Terratest turns into -var flags on the command line, so your test drives the module exactly the way a human caller would, with no test-only code path hiding inside the module. EnvVars are added to the environment of the terraform child process only, leaving your own shell untouched.
terraform.InitAndApply runs two commands back to back: terraform init -upgrade=false, then terraform apply -input=false -auto-approve with your -var flags appended. That -input=false matters more than it looks. It tells Terraform never to stop and ask a question, so a missing variable fails loudly in ten seconds instead of hanging a build agent until somebody notices. If either command exits non-zero, Terratest fails the test on the spot. When the call returns, the resources are real.
terraform.Output shells out to terraform output -no-color -json <name> and hands you back a string. terraform.OutputMap does the same for a map value and returns a map[string]string, flattening whatever JSON (JavaScript Object Notation, the text format Terraform uses for machine-readable output) types it finds into strings on the way. There is also OutputList for lists and OutputAll when you want every output in one call.
Two Ways to Fail a Test
testify (the assertion library nearly every Go project uses) hands you two packages that look identical and behave differently. assert records the failure and carries on, because underneath it calls t.Errorf. require records the failure and stops the test dead, because underneath it calls t.FailNow. One is the snag list the inspector hands you on the way out. The other is "this building has no floor, everybody out."
The rule of thumb: reach for require when everything after that line depends on that line being true, and assert when you want the whole picture out of one expensive run. Four public-access settings is the textbook assert case, because "three of your four guardrails are off" is a far more useful bug report than "the first one was off."
One Go detail matters more here than in ordinary code. require fails through t.FailNow, which calls runtime.Goexit, and Goexit runs that goroutine's deferred functions on its way out. So a require failure still triggers your defer terraform.Destroy. A panic inside the test goroutine runs its defers too. What does not is the process being killed from outside, which is the last section of this lesson. Note also that require only behaves on the test's own goroutine. Call it inside a go func() and Goexit quietly kills that goroutine while the test carries on believing all is well.
Set Up the Go Module and Run It
Terratest ships as an ordinary Go dependency, nothing exotic, though v1.0.1 declares go 1.26.0 in its own module file, so your toolchain has to be at least that. Make a test/ directory next to examples/, drop the test file in, then create a module and pull the library.
$ cd s3-private-bucket$ mkdir test # then save bucket_test.go into it$ cd test$ go mod init github.com/acme/s3-private-bucket/test$ go mod tidy
go: creating new go.mod: module github.com/acme/s3-private-bucket/testgo: to add module requirements and sums:go mod tidygo: finding module for package github.com/gruntwork-io/terratest/modules/randomgo: finding module for package github.com/gruntwork-io/terratest/modules/terraformgo: finding module for package github.com/stretchr/testify/assertgo: finding module for package github.com/stretchr/testify/requirego: downloading github.com/gruntwork-io/terratest v1.0.1go: downloading github.com/stretchr/testify v1.11.1go: found github.com/gruntwork-io/terratest/modules/random in github.com/gruntwork-io/terratest v1.0.1go: found github.com/stretchr/testify/assert in github.com/stretchr/testify v1.11.1go: downloading github.com/aws/aws-sdk-go-v2 v1.42.0go: downloading k8s.io/client-go v0.36.2go: downloading golang.org/x/crypto v0.49.0... (Terratest pulls in the AWS, Azure, GCP and Kubernetes SDKs, the vendorlibraries for talking to each cloud. Budget a few hundred megabytes and aminute or two on a cold module cache.)
module github.com/acme/s3-private-bucket/testgo 1.26.0require (github.com/gruntwork-io/terratest v1.0.1github.com/stretchr/testify v1.11.1)// go mod tidy also writes a long require (...) block of // indirect// dependencies below this one. Commit go.mod and go.sum so CI builds the// same dependency tree you did.
$ go test -v -timeout 30m -run TestPrivateBucketExample
=== RUN TestPrivateBucketExampleTestPrivateBucketExample 2026-07-22T09:41:12Z retry.go:159: terraform [init -upgrade=false]TestPrivateBucketExample 2026-07-22T09:41:12Z command.go:200: Running command terraform with args [init -upgrade=false]TestPrivateBucketExample 2026-07-22T09:41:13Z command.go:301: Initializing the backend...TestPrivateBucketExample 2026-07-22T09:41:13Z command.go:301: Initializing provider plugins...TestPrivateBucketExample 2026-07-22T09:41:13Z command.go:301: - Finding hashicorp/aws versions matching "~> 6.0"...TestPrivateBucketExample 2026-07-22T09:41:19Z command.go:301: - Installing hashicorp/aws v6.55.0...TestPrivateBucketExample 2026-07-22T09:41:31Z command.go:301: - Installed hashicorp/aws v6.55.0 (signed by HashiCorp)TestPrivateBucketExample 2026-07-22T09:41:31Z command.go:301: Terraform has been successfully initialized!TestPrivateBucketExample 2026-07-22T09:41:31Z retry.go:159: terraform [apply -input=false -auto-approve -var bucket_name=sol-audit-1kq9zx -var region=eu-west-1 -lock=false]TestPrivateBucketExample 2026-07-22T09:41:31Z command.go:200: Running command terraform with args [apply -input=false -auto-approve -var bucket_name=sol-audit-1kq9zx -var region=eu-west-1 -lock=false]... (the plan Terraform prints before applying is snipped here) ...TestPrivateBucketExample 2026-07-22T09:41:34Z command.go:301: aws_s3_bucket.audit: Creating...TestPrivateBucketExample 2026-07-22T09:41:37Z command.go:301: aws_s3_bucket.audit: Creation complete after 3s [id=sol-audit-1kq9zx]TestPrivateBucketExample 2026-07-22T09:41:37Z command.go:301: aws_s3_bucket_public_access_block.audit: Creating...TestPrivateBucketExample 2026-07-22T09:41:37Z command.go:301: aws_s3_bucket_server_side_encryption_configuration.audit: Creating...TestPrivateBucketExample 2026-07-22T09:41:39Z command.go:301: aws_s3_bucket_public_access_block.audit: Creation complete after 2s [id=sol-audit-1kq9zx]TestPrivateBucketExample 2026-07-22T09:41:39Z command.go:301: aws_s3_bucket_server_side_encryption_configuration.audit: Creation complete after 2s [id=sol-audit-1kq9zx]TestPrivateBucketExample 2026-07-22T09:41:39Z command.go:301: Apply complete! Resources: 3 added, 0 changed, 0 destroyed.... (Terraform's own Outputs: block is printed here too) ...TestPrivateBucketExample 2026-07-22T09:41:39Z retry.go:159: terraform [output -no-color -json bucket_id]TestPrivateBucketExample 2026-07-22T09:41:39Z command.go:200: Running command terraform with args [output -no-color -json bucket_id]TestPrivateBucketExample 2026-07-22T09:41:40Z command.go:301: "sol-audit-1kq9zx"TestPrivateBucketExample 2026-07-22T09:41:40Z retry.go:159: terraform [output -no-color -json public_access]TestPrivateBucketExample 2026-07-22T09:41:40Z command.go:200: Running command terraform with args [output -no-color -json public_access]TestPrivateBucketExample 2026-07-22T09:41:40Z command.go:301: {"block_public_acls":"true","block_public_policy":"true","ignore_public_acls":"true","restrict_public_buckets":"true"}TestPrivateBucketExample 2026-07-22T09:41:40Z retry.go:159: terraform [destroy -auto-approve -input=false -var bucket_name=sol-audit-1kq9zx -var region=eu-west-1 -lock=false]TestPrivateBucketExample 2026-07-22T09:41:40Z command.go:200: Running command terraform with args [destroy -auto-approve -input=false -var bucket_name=sol-audit-1kq9zx -var region=eu-west-1 -lock=false]TestPrivateBucketExample 2026-07-22T09:41:49Z command.go:301: Destroy complete! Resources: 3 destroyed.--- PASS: TestPrivateBucketExample (37.42s)PASSok github.com/acme/s3-private-bucket/test 38.01s
Read the command lines Terratest echoes back, because they are the real ones. The file:line in each prefix names the line of Terratest that printed it: retry.go:159 announces a command, command.go:200 reports the process starting, and command.go:301 relays whatever Terraform itself wrote. Those numbers move when you upgrade the library, so match on the message rather than the prefix. Notice -lock=false on apply and destroy. terraform.Options.Lock is a plain Go boolean that defaults to false, so Terratest switches state locking off unless you ask for it. Harmless for a throwaway fixture with local state. Genuinely dangerous the day someone points a fixture at a shared remote backend, where two runs at once can scribble over each other's state file. Set Lock: true in Options the moment a fixture stops using local state.
Three flags earn their place on that command. -run picks tests by regular expression, matched as a substring against the test name, so you are not deploying six fixtures while debugging one. -timeout 30m is the subject of the last section, and it is not optional. -v is the one people get wrong: Terratest writes its own log lines straight to standard output, so you see the Terraform stream with or without it. What -v buys you is the === RUN and --- PASS scaffolding, plus anything the test itself records through t.Log, which Go throws away for tests that pass. Keep it. Now run the identical command a second time, changing nothing at all.
$ go test -v -timeout 30m -run TestPrivateBucketExample
=== RUN TestPrivateBucketExample... every log line from the run above, replayed byte for byte from disk ...--- PASS: TestPrivateBucketExample (37.42s)PASSok github.com/acme/s3-private-bucket/test (cached)
Nothing deployed. Nothing was checked. The only tell is the word (cached) at the end of a report that otherwise looks exactly like a real inspection, because that is what it is: a photocopy of last month's, signed at the bottom. Go keeps successful test results and replays them, and it only invalidates that copy when the package's Go inputs change or when an environment variable the test itself read changes. Editing main.tf counts for nothing. Terraform files are invisible to the Go build cache, and terraform is a separate process Go cannot see inside. So you can weaken your module, rerun the suite, and get a cheerful green replay of yesterday's run against yesterday's code. That is a false pass on a security control, which is worse than having no test at all. -count=1 disables the cache. Put it on every infrastructure test command and never take it off.
Prove the Test Can Fail
A green test is a claim, and the claim is worth nothing until you have watched the test go red for the exact reason you care about. So break it on purpose. Flip one guardrail off in the example, rerun, and read the failure.
$ sed -i 's/block_public_policy = true/block_public_policy = false/' \../examples/private-bucket/main.tf$ go test -v -count=1 -timeout 30m -run TestPrivateBucketExample
=== RUN TestPrivateBucketExample... init and apply output as before ...TestPrivateBucketExample 2026-07-22T10:02:55Z retry.go:159: terraform [output -no-color -json public_access]TestPrivateBucketExample 2026-07-22T10:02:55Z command.go:200: Running command terraform with args [output -no-color -json public_access]TestPrivateBucketExample 2026-07-22T10:02:55Z command.go:301: {"block_public_acls":"true","block_public_policy":"false","ignore_public_acls":"true","restrict_public_buckets":"true"}bucket_test.go:41:Error Trace: /home/dev/s3-private-bucket/test/bucket_test.go:41Error: Not equal:expected: "true"actual : "false"Diff:--- Expected+++ Actual@@ -1 +1 @@-true+falseTest: TestPrivateBucketExampleTestPrivateBucketExample 2026-07-22T10:02:55Z retry.go:159: terraform [destroy -auto-approve -input=false -var bucket_name=sol-audit-7bd2mp -var region=eu-west-1 -lock=false]TestPrivateBucketExample 2026-07-22T10:03:04Z command.go:301: Destroy complete! Resources: 3 destroyed.--- FAIL: TestPrivateBucketExample (34.88s)FAILexit status 1FAIL github.com/acme/s3-private-bucket/test 35.40s
Two things to notice. The failure names the file, the line, the value you expected and the value that came back, which is the difference between a bug report and a shrug. And the destroy still ran, right there after the assertion failed, because the defer had been registered before anything was built. Revert the edit, watch it go green again, and you now have evidence the test can tell the two states apart. Without that evidence, a passing test only proves the test ran.
Whose Account Is This
The terraform module in Terratest does not authenticate to anything itself. It shells out to the terraform binary, which picks up credentials from the environment exactly as the CLI (command-line interface) does: AWS_PROFILE, or AWS_ACCESS_KEY_ID with AWS_SECRET_ACCESS_KEY and possibly AWS_SESSION_TOKEN, or a role assumed from an OIDC (OpenID Connect, a way for a build job to prove who it is without a stored key) token in CI (continuous integration, the server that runs your tests on every push). Whichever identity your shell is holding is the identity that applies and destroys. The destroy half is the part that hurts. Check whose keys are in your pocket before the first run, not after.
$ aws sts get-caller-identity
{"UserId": "AROA3XFRBF535PLBIFPI4:terratest","Account": "111122223333","Arn": "arn:aws:sts::111122223333:assumed-role/terratest-sandbox/terratest"}
AWS_PROFILE is enough for a test to apply into production and then run terraform destroy against it. Use a dedicated sandbox account with no production data and no network path to production, and confirm the account number before the first run. Second hazard: Terratest streams every line Terraform prints to standard output, which lands in build logs that are usually readable by the whole engineering org. Marking an output sensitive = true does not save you, because terraform output -json prints sensitive values in full and that is the exact command Terratest runs. Never emit a password, token or key as a fixture output. If you have no choice, set Logger: logger.Discard in terraform.Options (from modules/logger) so that run writes nothing out.The Ten-Minute Cliff
Point the same harness at a heavier fixture, one with a managed database in it, and leave -timeout off the command. This is the most expensive mistake in Terratest, and it is one keystroke wide.
# note the missing -timeout$ go test -v -run TestAuditPipelineExample
=== RUN TestAuditPipelineExampleTestAuditPipelineExample 2026-07-22T10:20:11Z retry.go:159: terraform [init -upgrade=false]... apply output ...TestAuditPipelineExample 2026-07-22T10:29:51Z command.go:301: aws_db_instance.audit: Still creating... [9m40s elapsed]panic: test timed out after 10m0srunning tests:TestAuditPipelineExample (10m0s)goroutine 51 [running]:testing.(*M).startAlarm.func1()/usr/local/go/src/testing/testing.go:2366 +0x385created by time.goFunc/usr/local/go/src/time/sleep.go:215 +0x2dgoroutine 1 [chan receive, 10 minutes]:testing.(*T).Run(0xc0001a51e0, {0x1b3a2f1, 0x19}, 0x1c0e0a8)/usr/local/go/src/testing/testing.go:1751 +0x3ab... (full goroutine dump, roughly 200 more lines) ...exit status 2FAIL github.com/acme/audit-pipeline/test 600.021s
The important part of that log is what is missing. There is no terraform [destroy ...] line anywhere. go test enforces a default timeout of ten minutes. When it expires, Go's testing package panics from its own timer goroutine, the one it started with time.AfterFunc, and not from the goroutine your test is running on. A panic unwinds only the goroutine it happens on, and an unrecovered one takes the whole process down with it. Your defer terraform.Destroy never gets a turn. The database, the security group, the subnets and everything else apply had built by minute ten are still sitting there, still billing, and the process that knew their names is gone.
Recovery costs more than you would guess. If the run was on your laptop, the local terraform.tfstate in the example directory still lists every resource Terraform managed to create, so cd examples/audit-pipeline && terraform destroy reconciles it cleanly. If the run was on a throwaway build runner using local state, that file died with the container, and Terraform now has no record those resources ever existed. You are down to hunting by name in the console. That is exactly why every fixture should tag its resources with something you can search for, and why a scheduled sweep of the sandbox account is ordinary hygiene rather than paranoia.
go test kills the process at ten minutes unless you say otherwise, and because the panic is raised from testing's own timer goroutine it does not unwind your test's defers, so terraform.Destroy is skipped entirely. Size -timeout to the slowest realistic apply plus destroy for that fixture; 30 minutes is a sensible floor for anything touching a cloud API. Treat it as part of the command, not a knob you add after the first leak. Do not reach for -timeout 0 either, because that removes the deadline rather than extending it, and a wedged apply then runs until somebody notices the job. And if a run does get orphaned, reconcile it before you rerun, because a rerun generates a fresh unique name and stacks a second orphan on top of the first..PHONY: testTEST ?= .# -count=1 defeats the Go test cache. -timeout stops the process being killed# before defer terraform.Destroy gets to run. Neither one is optional.test:go test -v -count=1 -timeout 30m -run '$(TEST)' ./...
make test runs the suite, make test TEST=TestPrivateBucketExample runs one test. Nobody has to remember two flags at six in the evening, and the flag standing between you and a NAT gateway billing all weekend is checked into the repository instead of living in one engineer's shell history.
go test -v -timeout 30m -run TestPrivateBucketExample, and Go prints the old passing output followed by ok ... (cached). Why, and what fixes it?go test -v ./... with no -timeout. Twelve minutes in, the job prints panic: test timed out after 10m0s and a goroutine dump, then exits. The log contains apply output but no destroy output at all, and the bucket from that run still exists. What happened, and what do you do first?Try this
Run go mod init github.com/acme/s3-private-bucket/test 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: the test runs wherever your shell is pointed, and it echoes everything. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.