CoursesTerratestTest stages & speed

Test stages & speed

Skip stages; iterate fast.

Advanced12 min · lesson 7 of 12

You change one line in an assertion, run the test, and then you wait. Terraform builds a load balancer (the traffic cop that spreads incoming requests across your servers). It waits on a certificate. It waits on DNS (the internet's phone book, which turns a name like edge.example.com into a numeric address). Four and a half minutes later the test fails, because you compared a string to a number. You fix that. Four and a half minutes again. This loop is where infrastructure testing quietly dies. The price of trying something gets so high that you stop trying things, and the security assertions you meant to write never get written.

A kitchen does not rebuild the oven between tastings. You cook the sauce once, then taste, adjust, taste again. Terratest gives you the same split through test stages: you cut one test into named phases, and any phase can be told to sit this run out. Build the real infrastructure once. Then run the tasting step, your assertions, thirty times in a row at a few seconds each, against infrastructure that is already standing. Everything below was run against Terratest v1.0.1, which needs Go 1.26 or newer. The log prefixes shown are the ones that release prints. On another release you will see the same messages with the line numbers moved.

A Stage Is an If Statement With a Name

test_structure.RunTestStage(t, "validate", func(){ ... }) does exactly one thing before it decides whether to call your function: it reads an environment variable (a named value your shell hands to every program it starts) called SKIP_validate. Unset or empty, and your function runs. Holding any value at all, and Terratest prints a line saying it is skipping this stage, then moves on. The stage name you choose is pasted onto the fixed prefix SKIP_ character for character, so keep stage names lowercase with underscores. A stage called validate-tls would need SKIP_validate-tls=true, and most shells refuse to treat a hyphenated name as an assignment at all.

All the stages sit inside one Go test function, so during a single run they could hand values to each other in memory. The point of stages is that you run them in separate go test invocations, and each invocation is a brand new operating system process that remembers nothing about the last one. So Terratest leaves notes on the fridge. SaveTerraformOptions writes your options struct as JSON (JavaScript Object Notation, a plain-text data format) into a .test-data folder beside the module, and LoadTerraformOptions reads it back tomorrow morning. Whatever a later stage needs, an earlier stage has to write down.

Four stages earn their keep here. setup decides what you are deploying, including the random suffix that stops your resources colliding with a colleague's. deploy applies it. validate asserts. teardown destroys. Splitting setup away from deploy is the part people leave out and regret. Generate the random name inside deploy, and every re-run of deploy invents a fresh name, so Terraform dutifully destroys and rebuilds the entire thing instead of updating it in place. Generate it once in setup, write it to disk, and re-running deploy becomes a small incremental change.

test/edge_test.go
package test
import (
"crypto/tls"
"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"
test_structure "github.com/gruntwork-io/terratest/modules/test-structure"
"github.com/stretchr/testify/require"
)
func TestEdgeServiceHardened(t *testing.T) {
// A stable path. Stage handoff files land in <workingDir>/.test-data/.
workingDir := "../examples/edge-service"
// Deferred, so it runs last. Skipped while you iterate: the infra stays up.
defer test_structure.RunTestStage(t, "teardown", func() {
opts := test_structure.LoadTerraformOptions(t, workingDir)
terraform.Destroy(t, opts)
test_structure.CleanupTestData(t, test_structure.FormatTestDataPath(workingDir, "TerraformOptions.json"))
})
// setup: decide WHAT to deploy, once. The random name must not change on re-runs.
test_structure.RunTestStage(t, "setup", func() {
opts := &terraform.Options{
TerraformDir: workingDir,
Vars: map[string]interface{}{
"name": "edge-" + strings.ToLower(random.UniqueID()),
"aws_region": "eu-west-1",
},
RetryableTerraformErrors: map[string]string{
"RequestError: send request failed": "transient AWS API error",
},
MaxRetries: 3,
TimeBetweenRetries: 5 * time.Second,
}
test_structure.SaveTerraformOptions(t, workingDir, opts) // the note for later stages
})
// deploy: the slow, expensive part. Real AWS, real minutes, real money.
test_structure.RunTestStage(t, "deploy", func() {
terraform.InitAndApply(t, test_structure.LoadTerraformOptions(t, workingDir))
})
// validate: the stage you will run fifty times. Seconds, not minutes.
test_structure.RunTestStage(t, "validate", func() {
opts := test_structure.LoadTerraformOptions(t, workingDir)
host := terraform.Output(t, opts, "service_host")
// 1. It serves over TLS at all (retry while DNS and the load balancer warm up).
http_helper.HttpGetWithRetry(t, "https://"+host, nil, 200, "ok", 30, 10*time.Second)
// 2. It must REFUSE obsolete TLS. Only a live handshake can prove this.
_, _, err := http_helper.HttpGetE(t, "https://"+host, &tls.Config{
MinVersion: tls.VersionTLS10,
MaxVersion: tls.VersionTLS11,
})
require.Error(t, err, "the endpoint completed a TLS 1.1 handshake")
// Any error at all would satisfy the line above, including a DNS blip or an
// expired certificate. Pin down WHICH error, or one day this passes for the
// wrong reason and you never notice.
require.ErrorContains(t, err, "protocol version not supported")
})
}

Pay for the Deploy Once

The first run does everything except clean up. SKIP_teardown=true means the test finishes and the infrastructure stays standing, pass or fail. That single variable is the difference between a five-minute penalty per idea and a three-second one.

terminal
$ cd test
$ SKIP_teardown=true go test -v -count=1 -run '^TestEdgeServiceHardened$' -timeout 30m
output
=== RUN TestEdgeServiceHardened
TestEdgeServiceHardened 2026-07-22T09:14:02+01:00 test_structure.go:44: The 'SKIP_setup' environment variable is not set, so executing stage 'setup'.
TestEdgeServiceHardened 2026-07-22T09:14:02+01:00 save_test_data.go:228: Storing test data in ../examples/edge-service/.test-data/TerraformOptions.json so it can be reused later
TestEdgeServiceHardened 2026-07-22T09:14:02+01:00 save_test_data.go:246: Marshalled JSON: {"Stdin":null,"WarningsAsErrors":null,"Logger":null,"BackendConfig":null,"EnvVars":null,"Vars":{"aws_region":"eu-west-1","name":"edge-a7f2kq"},"RetryableTerraformErrors":{"RequestError: send request failed":"transient AWS API error"},"SshAgent":null,"TerraformBinary":"","TerraformDir":"../examples/edge-service","PlanFilePath":"","PluginDir":"","LockTimeout":"","ExtraArgs":{"Apply":null,"Destroy":null,"Get":null,"Init":null,"Plan":null,"Validate":null,"WorkspaceDelete":null,"WorkspaceSelect":null,"WorkspaceNew":null,"Output":null,"Show":null},"Targets":null,"MixedVars":null,"VarFiles":null,"TimeBetweenRetries":5000000000,"Parallelism":0,"OutputMaxLineSize":0,"MaxRetries":3,"NoStderr":false,"NoColor":false,"MigrateState":false,"Reconfigure":false,"Upgrade":false,"SetVarsAfterVarFiles":false,"Lock":false}
TestEdgeServiceHardened 2026-07-22T09:14:02+01:00 test_structure.go:44: The 'SKIP_deploy' environment variable is not set, so executing stage 'deploy'.
TestEdgeServiceHardened 2026-07-22T09:14:02+01:00 save_test_data.go:264: Loading test data from ../examples/edge-service/.test-data/TerraformOptions.json
TestEdgeServiceHardened 2026-07-22T09:14:02+01:00 retry.go:159: terraform [init -upgrade=false]
TestEdgeServiceHardened 2026-07-22T09:14:02+01:00 logger.go:79: Running command terraform with args [init -upgrade=false]
TestEdgeServiceHardened 2026-07-22T09:14:06+01:00 logger.go:79: Terraform has been successfully initialized!
TestEdgeServiceHardened 2026-07-22T09:14:06+01:00 retry.go:159: terraform [apply -input=false -auto-approve -var aws_region=eu-west-1 -var name=edge-a7f2kq -lock=false]
TestEdgeServiceHardened 2026-07-22T09:14:06+01:00 logger.go:79: Running command terraform with args [apply -input=false -auto-approve -var aws_region=eu-west-1 -var name=edge-a7f2kq -lock=false]
...
TestEdgeServiceHardened 2026-07-22T09:18:29+01:00 logger.go:79: Apply complete! Resources: 14 added, 0 changed, 0 destroyed.
TestEdgeServiceHardened 2026-07-22T09:18:29+01:00 test_structure.go:44: The 'SKIP_validate' environment variable is not set, so executing stage 'validate'.
TestEdgeServiceHardened 2026-07-22T09:18:29+01:00 save_test_data.go:264: Loading test data from ../examples/edge-service/.test-data/TerraformOptions.json
TestEdgeServiceHardened 2026-07-22T09:18:29+01:00 retry.go:159: terraform [output -no-color -json service_host]
TestEdgeServiceHardened 2026-07-22T09:18:30+01:00 logger.go:79: Running command terraform with args [output -no-color -json service_host]
TestEdgeServiceHardened 2026-07-22T09:18:30+01:00 logger.go:79: "edge-a7f2kq.sandbox.example.com"
TestEdgeServiceHardened 2026-07-22T09:18:30+01:00 retry.go:159: HTTP GET to URL https://edge-a7f2kq.sandbox.example.com
TestEdgeServiceHardened 2026-07-22T09:18:30+01:00 http_helper.go:101: Making an HTTP GET call to URL https://edge-a7f2kq.sandbox.example.com
TestEdgeServiceHardened 2026-07-22T09:18:30+01:00 retry.go:173: HTTP GET to URL https://edge-a7f2kq.sandbox.example.com returned an error: Get "https://edge-a7f2kq.sandbox.example.com": dial tcp: lookup edge-a7f2kq.sandbox.example.com: no such host. Sleeping for 10s and will try again.
TestEdgeServiceHardened 2026-07-22T09:18:40+01:00 retry.go:159: HTTP GET to URL https://edge-a7f2kq.sandbox.example.com
TestEdgeServiceHardened 2026-07-22T09:18:41+01:00 http_helper.go:101: Making an HTTP GET call to URL https://edge-a7f2kq.sandbox.example.com
TestEdgeServiceHardened 2026-07-22T09:18:41+01:00 http_helper.go:101: Making an HTTP GET call to URL https://edge-a7f2kq.sandbox.example.com
edge_test.go:62:
Error Trace: /home/sam/infra/test/edge_test.go:62
/home/sam/go/pkg/mod/github.com/gruntwork-io/[email protected]/modules/test-structure/test_structure.go:45
/home/sam/infra/test/edge_test.go:50
Error: An error is expected but got nil.
Test: TestEdgeServiceHardened
Messages: the endpoint completed a TLS 1.1 handshake
TestEdgeServiceHardened 2026-07-22T09:18:42+01:00 test_structure.go:47: The 'SKIP_teardown' environment variable is set, so skipping stage 'teardown'.
--- FAIL: TestEdgeServiceHardened (280.13s)
FAIL
FAIL github.com/acme/infra/test 280.649s
FAIL

Read the test_structure.go lines first. For every stage, Terratest tells you which variable it checked and what it decided, and the two messages come from two different lines of its source, so line 44 always means it ran and line 47 always means it skipped. Some other prefixes work the opposite way. retry.go:159 prints whatever description the caller handed it, and logger.go:79 is the line Terratest forwards through when your options carry no logger of their own, so both of those turn up in front of completely different messages. When a run does something you did not expect, the test_structure.go lines are where you look, because the usual explanation is a SKIP_ variable you exported an hour ago and forgot about.

Now look at the third line of that output, the one saying Marshalled JSON. Terratest prints the whole options struct to standard output every single time you save it. Every Terraform variable, every environment variable you set through EnvVars, in the clear, in your terminal scrollback and in your pipeline's build log where anyone with read access can find it months later. That is not a bug, it is how you debug a stage handoff. It is also the reason the rule further down this lesson exists: nothing secret goes in terraform.Options.

Then the failure. That is a finding, not a broken test. The module attached the load balancer's usual default policy, ELBSecurityPolicy-2016-08, which still negotiates TLS 1.0 and 1.1 (TLS, Transport Layer Security, is the encryption behind the padlock in your browser; those two old versions were dropped by every major browser in 2020 and formally retired by RFC 8996, an internet standards document, in 2021). Nothing in the Terraform code contains the word weak, so a scanner reading the source has no opinion about it. Only a live handshake settles the question. And notice what the failure did not cost you: the load balancer is still up and the certificate is still valid, so the next attempt does not start from nothing.

examples/edge-service/main.tf
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.this.arn
port = 443
protocol = "HTTPS"
certificate_arn = aws_acm_certificate_validation.this.certificate_arn
# Was ELBSecurityPolicy-2016-08 (the AWS default), which still negotiates
# TLS 1.0 and 1.1. This policy refuses anything below TLS 1.2.
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.this.arn
}
}

With the module fixed, the deployed listener is out of date. Keep SKIP_setup on so the saved options, and with them the saved random name, stay exactly as they were, and let deploy run again. Same name, same state file, so Terraform edits the one listener in place rather than rebuilding the world.

terminal
$ SKIP_setup=true SKIP_teardown=true go test -v -count=1 -run '^TestEdgeServiceHardened$' -timeout 30m
output
=== RUN TestEdgeServiceHardened
TestEdgeServiceHardened 2026-07-22T09:47:20+01:00 test_structure.go:47: The 'SKIP_setup' environment variable is set, so skipping stage 'setup'.
TestEdgeServiceHardened 2026-07-22T09:47:20+01:00 test_structure.go:44: The 'SKIP_deploy' environment variable is not set, so executing stage 'deploy'.
TestEdgeServiceHardened 2026-07-22T09:47:20+01:00 save_test_data.go:264: Loading test data from ../examples/edge-service/.test-data/TerraformOptions.json
TestEdgeServiceHardened 2026-07-22T09:47:21+01:00 retry.go:159: terraform [apply -input=false -auto-approve -var aws_region=eu-west-1 -var name=edge-a7f2kq -lock=false]
TestEdgeServiceHardened 2026-07-22T09:47:21+01:00 logger.go:79: Running command terraform with args [apply -input=false -auto-approve -var aws_region=eu-west-1 -var name=edge-a7f2kq -lock=false]
TestEdgeServiceHardened 2026-07-22T09:48:05+01:00 logger.go:79: Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
TestEdgeServiceHardened 2026-07-22T09:48:05+01:00 test_structure.go:44: The 'SKIP_validate' environment variable is not set, so executing stage 'validate'.
TestEdgeServiceHardened 2026-07-22T09:48:05+01:00 save_test_data.go:264: Loading test data from ../examples/edge-service/.test-data/TerraformOptions.json
TestEdgeServiceHardened 2026-07-22T09:48:06+01:00 retry.go:159: terraform [output -no-color -json service_host]
TestEdgeServiceHardened 2026-07-22T09:48:07+01:00 retry.go:159: HTTP GET to URL https://edge-a7f2kq.sandbox.example.com
TestEdgeServiceHardened 2026-07-22T09:48:07+01:00 http_helper.go:101: Making an HTTP GET call to URL https://edge-a7f2kq.sandbox.example.com
TestEdgeServiceHardened 2026-07-22T09:48:08+01:00 http_helper.go:101: Making an HTTP GET call to URL https://edge-a7f2kq.sandbox.example.com
TestEdgeServiceHardened 2026-07-22T09:48:11+01:00 test_structure.go:47: The 'SKIP_teardown' environment variable is set, so skipping stage 'teardown'.
--- PASS: TestEdgeServiceHardened (51.42s)
PASS
ok github.com/acme/infra/test 51.903s

The Inner Loop

Now the fast part. The infrastructure is correct and standing, so every further assertion you write costs you one validate run, and a validate run is a terraform output call plus a couple of HTTP requests. Export the three skips once and stop typing them.

terminal
$ export SKIP_setup=true SKIP_deploy=true SKIP_teardown=true
$ time go test -v -count=1 -run '^TestEdgeServiceHardened$' -timeout 30m
output
=== RUN TestEdgeServiceHardened
TestEdgeServiceHardened 2026-07-22T10:03:11+01:00 test_structure.go:47: The 'SKIP_setup' environment variable is set, so skipping stage 'setup'.
TestEdgeServiceHardened 2026-07-22T10:03:11+01:00 test_structure.go:47: The 'SKIP_deploy' environment variable is set, so skipping stage 'deploy'.
TestEdgeServiceHardened 2026-07-22T10:03:11+01:00 test_structure.go:44: The 'SKIP_validate' environment variable is not set, so executing stage 'validate'.
TestEdgeServiceHardened 2026-07-22T10:03:11+01:00 save_test_data.go:264: Loading test data from ../examples/edge-service/.test-data/TerraformOptions.json
TestEdgeServiceHardened 2026-07-22T10:03:11+01:00 retry.go:159: terraform [output -no-color -json service_host]
TestEdgeServiceHardened 2026-07-22T10:03:12+01:00 logger.go:79: Running command terraform with args [output -no-color -json service_host]
TestEdgeServiceHardened 2026-07-22T10:03:12+01:00 logger.go:79: "edge-a7f2kq.sandbox.example.com"
TestEdgeServiceHardened 2026-07-22T10:03:12+01:00 retry.go:159: HTTP GET to URL https://edge-a7f2kq.sandbox.example.com
TestEdgeServiceHardened 2026-07-22T10:03:12+01:00 http_helper.go:101: Making an HTTP GET call to URL https://edge-a7f2kq.sandbox.example.com
TestEdgeServiceHardened 2026-07-22T10:03:13+01:00 http_helper.go:101: Making an HTTP GET call to URL https://edge-a7f2kq.sandbox.example.com
TestEdgeServiceHardened 2026-07-22T10:03:13+01:00 test_structure.go:47: The 'SKIP_teardown' environment variable is set, so skipping stage 'teardown'.
--- PASS: TestEdgeServiceHardened (1.83s)
PASS
ok github.com/acme/infra/test 2.611s
real 0m3.104s
user 0m0.489s
sys 0m0.226s

Three seconds. That number changes what you are willing to write. At four and a half minutes a run, you write two assertions and call the module tested. At three seconds you write the awkward ones: the cloud metadata endpoint is not reachable through the proxy, the health path does not echo the instance ID, the error page does not print the framework version, plain HTTP redirects instead of serving. Those are the checks that catch the misconfiguration an attacker actually walks through, and they are precisely the ones nobody writes when every attempt costs five minutes.

One deploy, many validate runs
1Run 1
SKIP_teardown=true. setup, deploy, validate. About 4.5 min, infra stays up.
2Runs 2 to N
SKIP_setup + SKIP_deploy + SKIP_teardown. validate only, about 3 s per edit.
3After a module edit
SKIP_setup + SKIP_teardown. Incremental apply, same name, about 50 s.
4Last run of the day
SKIP_setup + SKIP_deploy + SKIP_validate. Destroy, then clean .test-data.
5Before you push
No SKIP_ variables at all. Full cycle, exactly what the pipeline runs.
Only the first and last runs pay for real deploy time. Everything in between talks to the same standing infrastructure.
SKIP_ checks for empty, not for true
Terratest only asks whether the variable holds any value at all. SKIP_teardown=false skips the teardown. So does SKIP_teardown=0. Only an unset or empty variable runs the stage, so there is no such thing as switching a skip off by setting it to something false-looking. Worse, once you export a skip it lives in that shell until you close it, which is how the run you thought was a clean full cycle at 6pm leaves a public load balancer up all weekend on your credentials. Unset the variables or open a new terminal, and keep a scheduled sweep in the sandbox account that destroys anything tagged for testing and older than a couple of hours.

What the Stages Leave on Disk

The handoff shelf is a folder called .test-data sitting next to your example, and it pays to open it once so you know what you are storing there. Start with the line every repository that uses staged tests needs.

.gitignore
# Terratest stage handoff files. These hold every Terraform variable you
# passed, in plaintext, so they must never reach a commit.
**/.test-data/
terminal
$ cd ~/infra
$ ls -l examples/edge-service/.test-data/
$ jq '{TerraformDir, Vars, MaxRetries, TimeBetweenRetries, Logger, SshAgent, EnvVars}' \
examples/edge-service/.test-data/TerraformOptions.json
$ git check-ignore -v examples/edge-service/.test-data/TerraformOptions.json
output
total 4
-rw-r--r-- 1 sam sam 813 Jul 22 09:14 TerraformOptions.json
{
"TerraformDir": "../examples/edge-service",
"Vars": {
"aws_region": "eu-west-1",
"name": "edge-a7f2kq"
},
"MaxRetries": 3,
"TimeBetweenRetries": 5000000000,
"Logger": null,
"SshAgent": null,
"EnvVars": null
}
.gitignore:7:**/.test-data/ examples/edge-service/.test-data/TerraformOptions.json

Three things in that output are worth knowing. TimeBetweenRetries reads 5000000000 because Go measures durations in nanoseconds, which is correct and looks alarming the first time. Logger and SshAgent (the helper that holds your SSH keys in memory so you do not retype a passphrase) come back as null, and they would come back empty even if you had set them, because only plain data survives a trip through JSON. That has a sharp edge: set Logger: logger.Discard to keep a noisy stage quiet, save the options, reload them in the next stage, and the reloaded logger is empty, so Terratest quietly falls back to the default and prints everything again. And Vars is your variables, in the clear, in a file with permissions 0644, which on Linux means every user account on that machine can read it.

Keep secrets out of terraform.Options entirely
SaveTerraformOptions writes whatever is in Vars and EnvVars to disk in plaintext and prints the same content to standard output on the Marshalled JSON line. If your module takes a database password, an API token, or an admin CIDR (a compact way of writing a range of IP addresses, one you would rather nobody enumerated), that value is now sitting in your working tree, readable by anything running as you, echoed into your build log, and one git add -A away from being permanent history. Ignore **/.test-data/, and pass secrets through the ambient environment instead: export TF_VAR_db_password in your shell or pull it from the pipeline's secret store, and Terraform reads it directly without Terratest ever writing it down.

Shrinking the Loop Further

Stages are cheap, so cut finer wherever it pays. If validate takes ninety seconds because one assertion waits for DNS to spread across the internet's name servers, split it into validate_dns, validate_tls and validate_headers, then skip the slow one while you work on the other two. The same move handles expensive preparation: a Packer image build (Packer bakes a reusable machine image) or a container image push belongs in its own build_image stage you skip for days at a time. For Kubernetes work the split is usually deploy_cluster, install_chart, validate, because the cluster takes fifteen minutes to appear and the Helm release (Helm is the package manager for Kubernetes) takes forty seconds. You will reinstall that chart a hundred times against one cluster, and the k8s and helm modules never know the difference.

One helper changes its behaviour depending on whether you are iterating, and the surprise is worth understanding. CopyTerraformFolderToTemp copies your module into a fresh temporary directory so two tests running side by side cannot tread on each other's state files. Terratest already knows about stages: if any variable whose name starts with SKIP_ is set, it decides you are working locally, skips the copy entirely, logs that it is using the original examples folder, and hands you back the real module path. Note the width of that check. It looks at the prefix only, so an unrelated SKIP_LINT=1 left over from another tool is enough to turn the copying off. On a clean run with no skips at all, which is exactly what your pipeline does, every call makes a brand new copy. Call it in setup and again in validate and you get two different folders, and the second one has never seen a terraform apply. Call it once, in setup, and write the path down like everything else.

test/edge_test.go
// setup: copy once, then remember where the copy went.
test_structure.RunTestStage(t, "setup", func() {
tmp := test_structure.CopyTerraformFolderToTemp(t, "../", "examples/edge-service")
test_structure.SaveString(t, workingDir, "tempFolder", tmp)
// ...then build terraform.Options with TerraformDir: tmp and save them too.
})
// Every later stage reads the same path back:
tmp := test_structure.LoadString(t, workingDir, "tempFolder")
// e.g. /tmp/TestEdgeServiceHardened3172884091/infra/examples/edge-service
// (temp dir, then the copied root folder's own name, then your module path)
// NOTE: the anchor stays the stable repo path (workingDir), never the temp
// path. Anchor the notes to a folder that moves and the next run cannot
// find them.

Three go test flags earn their place in every command above. -timeout 30m, because the default is ten minutes and a real deploy walks straight through it. That one is a safety flag rather than a convenience. A failed assertion still runs your deferred teardown, because failing a Go test unwinds the test goroutine (a lightweight thread) and unwinding runs deferred functions on the way out. A test timeout does not. The timeout fires from a separate watchdog that panics the whole process, and a panicking process runs no deferred code and destroys nothing. -count=1, because Go caches passing results per package and will cheerfully print ok ... (cached) without running your test at all; the cache watches the files and environment variables your test reads, and it cannot watch the cloud. And -run '^TestEdgeServiceHardened$' with both anchors, because -run TestEdge is a regular expression (a pattern, not a literal name) that also matches TestEdgeDeepScan, and you tend to discover that when the bill arrives.

Giving the Resources Back

When you are done for the day, one command runs the teardown stage and nothing else. Skip setup, skip deploy, skip validate, and the only thing left in the function is the deferred destroy.

terminal
$ SKIP_setup=true SKIP_deploy=true SKIP_validate=true \
go test -v -count=1 -run '^TestEdgeServiceHardened$' -timeout 30m
output
=== RUN TestEdgeServiceHardened
TestEdgeServiceHardened 2026-07-22T17:52:04+01:00 test_structure.go:47: The 'SKIP_setup' environment variable is set, so skipping stage 'setup'.
TestEdgeServiceHardened 2026-07-22T17:52:04+01:00 test_structure.go:47: The 'SKIP_deploy' environment variable is set, so skipping stage 'deploy'.
TestEdgeServiceHardened 2026-07-22T17:52:04+01:00 test_structure.go:47: The 'SKIP_validate' environment variable is set, so skipping stage 'validate'.
TestEdgeServiceHardened 2026-07-22T17:52:04+01:00 test_structure.go:44: The 'SKIP_teardown' environment variable is not set, so executing stage 'teardown'.
TestEdgeServiceHardened 2026-07-22T17:52:04+01:00 save_test_data.go:264: Loading test data from ../examples/edge-service/.test-data/TerraformOptions.json
TestEdgeServiceHardened 2026-07-22T17:52:05+01:00 retry.go:159: terraform [destroy -auto-approve -input=false -var aws_region=eu-west-1 -var name=edge-a7f2kq -lock=false]
TestEdgeServiceHardened 2026-07-22T17:52:05+01:00 logger.go:79: Running command terraform with args [destroy -auto-approve -input=false -var aws_region=eu-west-1 -var name=edge-a7f2kq -lock=false]
TestEdgeServiceHardened 2026-07-22T17:55:22+01:00 logger.go:79: Destroy complete! Resources: 14 destroyed.
TestEdgeServiceHardened 2026-07-22T17:55:22+01:00 save_test_data.go:347: Cleaning up test data from ../examples/edge-service/.test-data/TerraformOptions.json
--- PASS: TestEdgeServiceHardened (198.72s)
PASS
ok github.com/acme/infra/test 199.204s

That last log line matters more than it looks. CleanupTestData tears up the note. Leave the note on the shelf and tomorrow's SKIP_deploy=true run loads options that point at a state file with nothing in it, and terraform.Output fails with Output "service_host" not found. It reads like a broken module. It is a stale piece of paper describing infrastructure you destroyed last night.

Before You Push

Stage skipping is a local tool with one sharp edge, and that edge points at your pipeline. The same variable that saves you five minutes turns a CI job (continuous integration, the server that runs your tests on every change) into a liar. SKIP_validate=true in a pipeline produces a green build that deployed real infrastructure, asserted absolutely nothing, and reported success. Nobody sets that on purpose. Somebody sets it at 6pm on a Friday to stop a flaky assertion blocking a release, and it is still there eight months later while the dashboard insists the module is tested.

terminal
# from the repo root, on the branch you are about to push
$ unset SKIP_setup SKIP_deploy SKIP_validate SKIP_teardown
$ env | grep '^SKIP_' || echo 'no stage skips set'
$ grep -rn 'SKIP_' .github/workflows/ Makefile
$ cd test && go test -v -count=1 -timeout 45m ./...
output
no stage skips set
.github/workflows/terratest.yml:38: SKIP_validate: "true"
Makefile:14: SKIP_teardown=true go test -v -count=1 -timeout 30m ./test/...
=== RUN TestEdgeServiceHardened
TestEdgeServiceHardened 2026-07-22T11:32:15+01:00 test_structure.go:44: The 'SKIP_setup' environment variable is not set, so executing stage 'setup'.
... deploy and validate executing, full apply, all assertions running ...
TestEdgeServiceHardened 2026-07-22T11:38:02+01:00 test_structure.go:44: The 'SKIP_teardown' environment variable is not set, so executing stage 'teardown'.
TestEdgeServiceHardened 2026-07-22T11:38:02+01:00 retry.go:159: terraform [destroy -auto-approve -input=false -var aws_region=eu-west-1 -var name=edge-9dq4mx -lock=false]
TestEdgeServiceHardened 2026-07-22T11:41:44+01:00 logger.go:79: Destroy complete! Resources: 14 destroyed.
TestEdgeServiceHardened 2026-07-22T11:41:44+01:00 save_test_data.go:347: Cleaning up test data from ../examples/edge-service/.test-data/TerraformOptions.json
--- PASS: TestEdgeServiceHardened (569.31s)
PASS
ok github.com/acme/infra/test 569.812s

The Makefile hit is fine, that is the local iterate target doing its job. The workflow hit is a lie written in YAML (Yet Another Markup Language, the indented text format most pipelines are configured in), and it has been passing builds that assert nothing since somebody got tired of a red X. Grep for SKIP_ in your pipeline configuration before you trust a green Terratest run, and keep that scheduled sweep in the sandbox account, because a skipped teardown and a timed-out test leave behind exactly the same thing: live infrastructure, on your credentials, that nobody is looking at.

Quick check
01Your test has a stage named validate_tls. What makes Terratest skip it?
Incorrect — -run filters Go test functions by name; it knows nothing about stages inside a function.
Correct — RunTestStage builds the name SKIP_ plus the stage name and runs the stage only when that variable is unset or empty.
Incorrect — That ends the whole test at that point, so every later stage would be affected too, including the deferred teardown.
Incorrect — .test-data carries values between stages and has no influence on which stages run.
02You finish a session and run SKIP_teardown=false go test -v -run '^TestEdge$' -timeout 30m. What happens?
Incorrect — Terratest never parses the value; it checks only whether the variable is non-empty.
Incorrect — There is no validation of the value at all, so nothing errors out.
Incorrect — Nothing separates those two; they sit inside the same stage function and run together or not at all.
Correct — false is a non-empty value, so it skips the stage exactly as true would.
03Yesterday you destroyed everything with a teardown-only run, but CleanupTestData was commented out. Today SKIP_setup=true SKIP_deploy=true SKIP_teardown=true go test -v -count=1 -run '^TestEdge$' fails with: Error: Output "service_host" not found. The output variable requested could not be found in the state file. What is happening?
Incorrect — The output is declared; the state file has nothing in it because the infrastructure was destroyed yesterday.
Incorrect — Output shells out to terraform output -no-color -json and reads state from disk, so it works fine in a separate run.
Correct — the handoff note outlived the destroy, so the skipped stages pointed validate at an empty state file.
Incorrect — -count=1 is already in the command, and a cached result would have replayed a pass rather than a fresh Terraform error.

Try this

Run time go test -v -count=1 -run '^TestEdgeServiceHardened$' -timeout 30m 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: sKIP_ checks for empty, not for true. 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