CoursesTerratestThe IaC testing landscape

The IaC testing landscape

Native tests, InSpec, Checkov.

Advanced10 min · lesson 12 of 12

Terratest is the most expensive test you own. It builds real infrastructure in a real cloud account, on a real invoice, and one run takes minutes where a linter takes milliseconds. You pay that price for the one thing nothing cheaper can give you: proof that the thing you built actually behaves.

A car factory does not put every idea on the racetrack. Someone reads the drawings first and spots the brake line routed against a hot exhaust. Then the parts go on a bench rig driven by a simulated engine. Only after that does a finished car go out on the track with fuel in it. And once cars are on the road, an inspector checks the ones already out there, whoever built them and however long ago. Four checks, four costs, four kinds of defect. Infrastructure as code (IaC, meaning your networks and servers are written down in text files and created by a tool) has exactly those four, and Terratest is the track day.

Each layer answers a question the layer before it cannot. Does this configuration contain a known bad pattern? Does the logic inside my module do what I claimed? Does the deployed thing actually work? Is it still correct three weeks later, after other people have been in the account? Get the order wrong and you spend twelve minutes and a load balancer discovering that you typed 0.0.0.0/0 (every address on the internet) into a firewall rule. When a Terratest run goes red, the first question worth asking is which cheaper check should have caught it.

The gate that never touches your cloud

Checkov is a proofreader for the drawings. It reads your Terraform files, matches every resource against a library of more than a thousand rules, and prints the ones that look wrong: an S3 bucket (Simple Storage Service, Amazon's file store) open to the public internet, a disk with no encryption, a security group (the firewall wrapped around a machine) that lets the whole internet reach port 22, the port used for remote login over SSH (Secure Shell). No cloud credentials. Nothing created. Seconds, not minutes. It reads Kubernetes manifests, Helm charts, Dockerfiles and CloudFormation as well, so one gate covers most of what your pipeline ships.

terminal
# every push: read the source, no credentials, nothing created
checkov -d . --compact --quiet
echo "exit code: $?"
output
terraform scan results:
Passed checks: 46, Failed checks: 3, Skipped checks: 0
Check: CKV_AWS_23: "Ensure every security group and rule has a description"
FAILED for resource: aws_security_group.bastion
File: /network/sg.tf:31-48
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-networking-policies/networking-31
Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"
FAILED for resource: aws_security_group.bastion
File: /network/sg.tf:31-48
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-networking-policies/networking-1-port-security
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
FAILED for resource: aws_s3_bucket.logs
File: /modules/logging/main.tf:4-11
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/s3-policies/s3-13-enable-logging
exit code: 1

--quiet prints only the failures and swallows the ASCII banner, --compact leaves out the code snippet under each finding, and the exit code flips to 1 the moment anything fails, which is what turns a scan into a gate. That run only reads what you typed, though. The stronger move is to scan the plan, which is Terraform's own written-out preview of the changes it is about to make. Variables get resolved and modules get expanded, so the scanner sees the resource that will really exist instead of a placeholder like var.name_prefix. Watch the number of checked resources climb.

terminal
# stronger: scan Terraform's own preview of the change
terraform plan -out=tfplan.bin
terraform show -json tfplan.bin > tfplan.json
checkov -f tfplan.json \
--repo-root-for-plan-enrichment . \
--deep-analysis \
--compact --quiet
echo "exit code: $?"
output
terraform_plan scan results:
Passed checks: 57, Failed checks: 1, Skipped checks: 1
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
FAILED for resource: module.logging.aws_s3_bucket.logs
File: /modules/logging/main.tf:4-11
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/s3-policies/s3-13-enable-logging
exit code: 1

Two flags earn their keep there. --repo-root-for-plan-enrichment . maps each finding back to the source file and line that produced it, so the failure points at modules/logging/main.tf instead of at a blob of JSON (JavaScript Object Notation, a plain text data format). --deep-analysis links the plan results back to the raw HCL (HashiCorp Configuration Language, the syntax your .tf files are written in) so rules that need both views can fire. Now look at the number that changed quietly: one skipped check. That is the rule about encrypting buckets with KMS (Key Management Service, where you keep encryption keys you control), and how it got skipped matters. checkov -d . --skip-check CKV_AWS_145 silences a rule everywhere, invisibly, forever. A comment on the resource instead, #checkov:skip=CKV_AWS_145:log data is not customer data, ticket SEC-4412, turns up in code review, and if you drop --quiet the report prints SKIPPED for resource: module.logging.aws_s3_bucket.logs with Suppress comment: log data is not customer data, ticket SEC-4412 on the line below it. An exception nobody can see is how a bad default outlives the person who accepted it.

Your plan file is a secrets file
terraform show -json tfplan.bin writes out every value the plan knows, and the sensitive ones sit in there as readable text with a flag beside them saying they are sensitive. Database passwords, tokens, private keys. Plenty of pipelines generate tfplan.json for a scanner and then upload it as a build artifact, where anyone with read access to the pipeline can download it weeks later. Keep the file inside one job, delete it before the job ends, never commit it, never publish it. If the findings need to travel, publish the scanner's own report (checkov -o json, or -o sarif for the Static Analysis Results Interchange Format that your code host knows how to display), not the plan.

Be honest about the ceiling. A static scan reads intent. It cannot tell you that the load balancer answers, that the identity policy (IAM, Identity and Access Management, the rules about who is allowed to do what) really denies the call it was written to deny, or that anything exists outside the files it read. Green means no known bad pattern in this text. It does not mean safe.

Terraform's own test runner

Since version 1.6, the Terraform command line has shipped its own test runner. You write files ending in .tftest.hcl, in the module root or in a tests/ directory, in the same HCL you already write. No Go, no extra toolchain, nothing to install. Each file holds run blocks that execute top to bottom and share one throwaway state, and each run picks a mode: command = plan works out what would happen, command = apply builds it for real. apply is what you get when you leave the line out, which is a detail worth carving into your desk.

tests/s3.tftest.hcl
# run from the module directory with: terraform test
variables {
name_prefix = "acme-app"
environment = "test"
}
run "name_is_shaped_correctly" {
command = plan # nothing is created
assert {
condition = aws_s3_bucket.logs.bucket == "acme-app-logs"
error_message = "the name_prefix input did not reach the bucket name"
}
}
run "rejects_an_illegal_name" {
command = plan
variables {
name_prefix = "Acme_App" # capitals and underscores are illegal for S3
}
# the validation block on var.name_prefix must reject this
expect_failures = [var.name_prefix]
}
run "arn_is_populated" {
command = plan # this one cannot work. see the output.
assert {
condition = aws_s3_bucket.logs.arn != ""
error_message = "bucket arn missing"
}
}
terminal
# providers still get installed. no backend, no remote state.
terraform init -backend=false
terraform test
output
Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v6.55.0
Terraform has been successfully initialized!
tests/naming.tftest.hcl... in progress
run "prefix_reaches_every_bucket"... pass
tests/naming.tftest.hcl... tearing down
tests/naming.tftest.hcl... pass
tests/s3.tftest.hcl... in progress
run "name_is_shaped_correctly"... pass
run "rejects_an_illegal_name"... pass
run "arn_is_populated"... fail
Error: Unknown condition value
on tests/s3.tftest.hcl line 32, in run "arn_is_populated":
32: condition = aws_s3_bucket.logs.arn != ""
├────────────────
│ aws_s3_bucket.logs.arn is a string, known only after apply
Condition expression could not be evaluated at this time. This means you have
executed a `run` block with `command = plan` and one of the values your
condition depended on is not known until after the plan has been applied.
Either remove this value from your condition, or execute an `apply` command
from this `run` block. Alternatively, if there is an override for this value,
you can make it available during the plan phase by setting `override_during =
plan` in the `override_` block.
tests/s3.tftest.hcl... tearing down
tests/s3.tftest.hcl... fail
Failure! 3 passed, 1 failed.

That third failure is the most useful thing in this lesson about plan mode. A bucket's ARN (Amazon Resource Name, the unique identifier the cloud hands out when it creates something) does not exist until the bucket does. Plan mode can only assert on values that are already known: names you built, counts you computed, tags you set, outputs shaped from inputs. Anything the provider hands back after creation is out of reach. One more surprise: plan mode is not credential-free. Terraform still configures the provider, and the AWS provider checks who you are through STS (Security Token Service, the part of AWS that answers the question "whose key is this?") and reads any data sources you declared. A read-only role is enough, but "no credentials at all" is wrong.

Terraform 1.7 added the fix: a stunt double. mock_provider "aws" {} swaps the real provider for a stand-in that invents its answers, so command = apply runs your whole configuration without making a single API (application programming interface, the machine-to-machine door into a service) call. Nothing created, nothing destroyed, nothing billed.

tests/naming.tftest.hcl
# Terraform 1.7+: a stand-in provider, so command = apply never calls AWS
mock_provider "aws" {}
variables {
name_prefix = "tt-gate"
}
run "prefix_reaches_every_bucket" {
command = apply # no API calls, nothing to destroy, nothing to pay for
assert {
condition = startswith(aws_s3_bucket.logs.bucket, "tt-gate-")
error_message = "the name_prefix input did not reach the bucket name"
}
}

The trade is real. Mocked attributes are invented strings, so asserting that an ARN looks like an ARN proves nothing, and a mock will never tell you the cloud would have rejected the request. Mocks check your logic. They do not check the provider's opinion of it. Terraform still installs the provider during init even when you mock it, because it needs the schema to know which attributes exist.

Four checks, three price brackets
Free, runs in seconds
Checkov
rule library over HCL or plan JSON
terraform test, plan mode
real assertions, nothing created, read-only creds
mock_provider
apply-mode logic against a faked cloud
Meter running
terraform test, apply mode
real resources, destroyed when the file ends
Terratest
real deploy, HTTP and Kubernetes checks
After the fact, forever
InSpec profile
audits the live account on a schedule
Console changes and drift
only a live audit ever sees these
Every failure you can move one column to the left is a load balancer you never rent. Move right only when the question needs a running system to answer it.

What only a real deploy can answer

Now the track day. Terratest is a Go library, so a test is an ordinary Go function that drives the Terraform binary, waits, then pokes the result the way a user or an attacker would. It applies your module, reads the outputs, calls the URL, waits for the pod. Alongside modules/terraform there are modules/k8s and modules/helm for the Kubernetes side of the same trick. The two assertions below are ones no scanner and no plan can make for you: that HTTPS (the padlocked version of the web protocol) serves the app with a certificate a normal client accepts, and that plain HTTP gets bounced rather than served.

test/alb_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/terraform"
test_structure "github.com/gruntwork-io/terratest/modules/test-structure"
"github.com/stretchr/testify/require"
)
func TestAlbRedirectsPlaintext(t *testing.T) {
t.Parallel()
// t.Context() is cancelled after the test function returns, which is after
// every defer inside it has run. Safe here. Not safe from t.Cleanup.
ctx := t.Context()
workingDir := "../examples/alb"
// deferred first so it runs last, even when an assertion blows up
defer test_structure.RunTestStage(t, "teardown", func() {
opts := test_structure.LoadTerraformOptions(t, workingDir)
terraform.DestroyContext(t, ctx, opts)
})
test_structure.RunTestStage(t, "deploy", func() {
opts := &terraform.Options{
TerraformDir: workingDir,
Vars: map[string]any{"name_prefix": "tt-gate"},
}
test_structure.SaveTerraformOptions(t, workingDir, opts)
terraform.InitAndApplyContext(t, ctx, opts)
})
test_structure.RunTestStage(t, "validate", func() {
opts := test_structure.LoadTerraformOptions(t, workingDir)
httpsURL := terraform.OutputContext(t, ctx, opts, "alb_url")
// 1. it serves over TLS, certificate verified for real (nil = Go defaults)
http_helper.HTTPGetWithRetryContext(t, ctx, httpsURL, nil, 200, "OK", 30, 10*time.Second)
// 2. plaintext must be bounced, not served. Go's client chases redirects,
// so build one that stops and hands back the 301 itself.
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
plainURL := strings.Replace(httpsURL, "https://", "http://", 1)
resp, err := client.Get(plainURL)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusMovedPermanently, resp.StatusCode)
require.True(t, strings.HasPrefix(resp.Header.Get("Location"), "https://"),
"expected a redirect to HTTPS, got %q", resp.Header.Get("Location"))
})
}

One naming detail before the assertions. Terratest reached version 1.0 in 2026, and every helper picked up a context-aware twin. terraform.InitAndApply, terraform.Destroy and terraform.Output still compile, but they are marked deprecated in favour of InitAndApplyContext, DestroyContext and OutputContext, and the HTTP helpers went the same way: HttpGetWithRetry is now HTTPGetWithRetryContext. Older tutorials and older repositories use the short names. They still work. Your editor will underline them.

Look at the third argument of HTTPGetWithRetryContext. nil means Go's default TLS (Transport Layer Security, the padlock in your browser) settings, so the certificate is verified properly, against the real hostname. It is tempting to swap that for a config that skips verification on the day the test goes red. Do not. That deletes the assertion instead of fixing it, and a test that trusts any certificate would happily pass against an attacker's.

The second assertion needs a hand-built client, and the reason is the sort of thing that quietly turns a test into decoration. Go's http.Client follows redirects on its own, and so does every Terratest HTTP helper, because they all wrap that same client. Ask a helper for http://your-app and it will chase the 301 across to HTTPS, hand you the 200 from the far end, and any redirect assertion you wrote passes without ever proving a redirect happened. Setting CheckRedirect to return http.ErrUseLastResponse tells the client to stop and give you the redirect itself. Then check that the Location header starts with https:// rather than matching it exactly, because a load balancer writes the port into that header: what comes back is https://logs.test.acme.dev:443/, not the tidy URL you started from. The retry numbers on the first assertion matter too. Thirty attempts, ten seconds apart, because a load balancer created four seconds ago is not serving yet, and a flaky test everyone reruns is worse than no test at all.

terminal
go test -v -timeout 30m -run TestAlbRedirectsPlaintext ./test/...
output
=== RUN TestAlbRedirectsPlaintext
=== PAUSE TestAlbRedirectsPlaintext
=== CONT TestAlbRedirectsPlaintext
TestAlbRedirectsPlaintext 2026-07-22T09:41:02Z test_structure.go:44: The 'SKIP_deploy' environment variable is not set, so executing stage 'deploy'.
TestAlbRedirectsPlaintext 2026-07-22T09:41:02Z command.go:200: Running command terraform with args [init -upgrade=false]
TestAlbRedirectsPlaintext 2026-07-22T09:41:19Z command.go:200: Running command terraform with args [apply -input=false -auto-approve -var name_prefix=tt-gate -lock=false]
TestAlbRedirectsPlaintext 2026-07-22T09:45:31Z command.go:301: aws_lb.this: Still creating... [2m50s elapsed]
TestAlbRedirectsPlaintext 2026-07-22T09:45:57Z command.go:301: Apply complete! Resources: 16 added, 0 changed, 0 destroyed.
TestAlbRedirectsPlaintext 2026-07-22T09:45:57Z test_structure.go:44: The 'SKIP_validate' environment variable is not set, so executing stage 'validate'.
TestAlbRedirectsPlaintext 2026-07-22T09:45:57Z command.go:200: Running command terraform with args [output -no-color -json alb_url]
TestAlbRedirectsPlaintext 2026-07-22T09:45:58Z retry.go:159: HTTP GET to URL https://logs.test.acme.dev
TestAlbRedirectsPlaintext 2026-07-22T09:45:58Z http_helper.go:101: Making an HTTP GET call to URL https://logs.test.acme.dev
TestAlbRedirectsPlaintext 2026-07-22T09:45:58Z retry.go:173: HTTP GET to URL https://logs.test.acme.dev returned an error: Get "https://logs.test.acme.dev": dial tcp 52.31.14.77:443: connect: connection refused. Sleeping for 10s and will try again.
TestAlbRedirectsPlaintext 2026-07-22T09:46:08Z retry.go:159: HTTP GET to URL https://logs.test.acme.dev
TestAlbRedirectsPlaintext 2026-07-22T09:46:08Z http_helper.go:101: Making an HTTP GET call to URL https://logs.test.acme.dev
TestAlbRedirectsPlaintext 2026-07-22T09:46:09Z test_structure.go:44: The 'SKIP_teardown' environment variable is not set, so executing stage 'teardown'.
TestAlbRedirectsPlaintext 2026-07-22T09:46:09Z command.go:200: Running command terraform with args [destroy -auto-approve -input=false -var name_prefix=tt-gate -lock=false]
TestAlbRedirectsPlaintext 2026-07-22T09:49:11Z command.go:301: Destroy complete! Resources: 16 destroyed.
--- PASS: TestAlbRedirectsPlaintext (489.31s)
PASS
ok github.com/acme/infra/test 489.402s

test_structure.RunTestStage splits the run into named stages, and each one switches off when an environment variable called SKIP_ plus the stage name is set. That turns an eight-minute feedback loop into a two-second one while you are still writing assertions.

terminal
# deploy once, keep the stack alive
SKIP_teardown=true go test -v -timeout 30m -run TestAlbRedirectsPlaintext ./test/...
# now iterate on the assertions against the stack that is already up
SKIP_deploy=true SKIP_teardown=true go test -v -timeout 30m -run TestAlbRedirectsPlaintext ./test/...
# and when you are done, let every stage run so the stack is destroyed
go test -v -timeout 30m -run TestAlbRedirectsPlaintext ./test/...
output
=== RUN TestAlbRedirectsPlaintext
=== PAUSE TestAlbRedirectsPlaintext
=== CONT TestAlbRedirectsPlaintext
TestAlbRedirectsPlaintext 2026-07-22T10:02:11Z test_structure.go:47: The 'SKIP_deploy' environment variable is set, so skipping stage 'deploy'.
TestAlbRedirectsPlaintext 2026-07-22T10:02:11Z test_structure.go:44: The 'SKIP_validate' environment variable is not set, so executing stage 'validate'.
TestAlbRedirectsPlaintext 2026-07-22T10:02:11Z command.go:200: Running command terraform with args [output -no-color -json alb_url]
TestAlbRedirectsPlaintext 2026-07-22T10:02:12Z retry.go:159: HTTP GET to URL https://logs.test.acme.dev
TestAlbRedirectsPlaintext 2026-07-22T10:02:12Z http_helper.go:101: Making an HTTP GET call to URL https://logs.test.acme.dev
TestAlbRedirectsPlaintext 2026-07-22T10:02:13Z test_structure.go:47: The 'SKIP_teardown' environment variable is set, so skipping stage 'teardown'.
--- PASS: TestAlbRedirectsPlaintext (2.14s)
PASS
ok github.com/acme/infra/test 2.216s

The run is cheap and the leak is not. Ten minutes of an application load balancer costs less than a penny. A NAT gateway (network address translation gateway, the box that lets private machines reach the internet) that nobody destroyed costs roughly 33 dollars a month before it charges you for traffic, every month, in an account nobody opens. A forgotten managed Kubernetes control plane runs about 73 dollars a month. The tests are not what shows up on the invoice. Forgetting to destroy is.

The word test does not mean free
Two of these tools bill you. A run block with command = apply creates real resources, and Terraform destroys them only when the whole test file finishes, in reverse order. A Go test destroys in its deferred cleanup, which does not run at all if go test hits its timeout: the default is ten minutes, the watchdog panics, the process dies, and your load balancer stays up. That is why -timeout 30m appears in every Terratest command in this course. Run both in a sandbox account nobody else uses, tag every resource the tests create, and put a scheduled sweeper in that account (Gruntwork's cloud-nuke, or a script of your own) that deletes anything tagged for tests and older than a few hours. In continuous integration the runner's disk is thrown away when the job ends, taking the local state file with it, so the sweeper is often the only thing standing between a killed job and a permanent bill.

Proving it is still true tomorrow

Terratest tells you the module was correct on the day it ran, in an account that no longer exists. That leaves an obvious hole: everything that happens to real infrastructure afterwards. Someone widens a security group during an incident and never puts it back. A cost-saving script turns off logging. A resource gets clicked into existence and never enters your Terraform files at all. InSpec is the roadworthiness inspection for cars already on the road. You write down what a live system must look like, and inspec exec goes and asks the running cloud.

The description lives in a small Ruby DSL (domain-specific language, a mini-language shaped for one job). The cloud checks themselves come from a resource pack, a bundle of ready-made resources you list as a dependency and pin to a release, exactly like any other library. Forgetting that dependency is the most common first failure, so start with the profile file.

inspec.yml
name: s3-hardening
title: Log bucket guardrails
version: 0.2.0
supports:
- platform: aws
depends:
- name: inspec-aws
url: https://github.com/inspec/inspec-aws/archive/v1.83.63.tar.gz
controls/s3.rb
control 's3-logs-hardened' do
impact 1.0
title 'Log bucket must be private and encrypted'
desc 'Checked hourly against the live account, not only at deploy time.'
describe aws_s3_bucket(bucket_name: 'acme-app-logs') do
it { should exist }
it { should_not be_public }
it { should have_versioning_enabled }
it { should have_default_encryption_enabled }
end
end
terminal
CHEF_LICENSE=accept-silent inspec exec . \
-t aws://eu-west-1 \
--reporter cli json:audit-2026-07-22.json
echo "exit code: $?"
output
Profile: Log bucket guardrails (s3-hardening)
Version: 0.2.0
Target: aws://eu-west-1
Target ID: 111122223333
× s3-logs-hardened: Log bucket must be private and encrypted (1 failed)
✔ S3 Bucket acme-app-logs is expected to exist
✔ S3 Bucket acme-app-logs is expected not to be public
✔ S3 Bucket acme-app-logs is expected to have versioning enabled
× S3 Bucket acme-app-logs is expected to have default encryption enabled
expected `S3 Bucket acme-app-logs.has_default_encryption_enabled?` to return true, got false
Profile Summary: 0 successful controls, 1 control failure, 0 controls skipped
Test Summary: 3 successful, 1 failure, 0 skipped
exit code: 100

Exit codes carry the meaning here, so wire them into the pipeline deliberately. 0 means everything passed. 100 means something failed. 101 means nothing failed but something was skipped, which usually means a control never ran and produced output that looks a lot like success. Treat 101 as a result that needs a human, never as a pass. 172 means the Chef licence was never accepted, which is what CHEF_LICENSE=accept-silent on the front of the command is there to prevent. When you cannot fix a finding today, write the exception down with an expiry date instead of deleting the control.

waivers.yaml
s3-logs-hardened:
expiration_date: 2026-09-30
run: false
justification: "S3-managed encryption accepted while the KMS rollout finishes. Owner: platform-sec. Ticket: SEC-4412"
terminal
CHEF_LICENSE=accept-silent inspec exec . \
-t aws://eu-west-1 \
--waiver-file waivers.yaml \
--reporter cli json:audit-2026-07-22.json
echo "exit code: $?"
output
Profile: Log bucket guardrails (s3-hardening)
Version: 0.2.0
Target: aws://eu-west-1
Target ID: 111122223333
↺ s3-logs-hardened: Log bucket must be private and encrypted
↺ Skipped control due to waiver condition: S3-managed encryption accepted while the KMS rollout finishes. Owner: platform-sec. Ticket: SEC-4412
Profile Summary: 0 successful controls, 0 control failures, 1 control skipped
Test Summary: 0 successful, 0 failures, 1 skipped
exit code: 101

That is the difference between a waiver and a skip. The waiver names an owner, carries a ticket number, prints its own reason in the report, and stops working on the 30th of September, at which point the control fails again and somebody has to decide, deliberately, whether to renew it. Notice too that the whole control collapses into one skipped result rather than four, because InSpec replaces the checks instead of running them. Two practical notes before you standardise on InSpec: version 5 and later refuse to start in an automated job until the licence is accepted without a prompt, and the recent commercial majors also expect a licence key, so check what your organisation is entitled to. CINC Auditor is a community-built, drop-in compatible rebuild if that turns into a blocker.

Wiring the four gates into one pipeline

Give each gate a trigger, a time budget and an account. Fast and free runs on every push, with read-only credentials at most. Slow and billed runs on merge, in a sandbox account that has its own spending limit and its own sweeper. The live audit runs on a schedule against production through a read-only role, and its report goes somewhere people actually read.

Makefile
.PHONY: gate-fast gate-deploy gate-audit
gate-fast: ## every push. under a minute. read-only credentials at most.
checkov -d . --compact --quiet
cd modules/logging && terraform init -backend=false && terraform test
gate-deploy: ## merge to main. about ten minutes. sandbox account only.
go test -v -timeout 30m ./test/...
gate-audit: ## hourly cron. read-only role in production.
CHEF_LICENSE=accept-silent inspec exec . -t aws://eu-west-1 \
--waiver-file waivers.yaml --reporter cli json:audit.json

One habit stops this rotting. Every time a Terratest run catches something, write down which cheaper gate could have caught it, add that rule or that plan-mode assertion, and then delete the deploy test if it has become redundant. A suite that only ever grows gets slower, pricier and less trusted every month, until somebody switches it off in a hurry and takes the check you actually needed down with it.

Quick check
01Your pipeline runs checkov -d . today. You change it to build a plan first and run checkov -f tfplan.json. What does that actually buy you?
Correct — the plan is Terraform's own expansion of your configuration, so a bucket name built from a variable is a concrete value by the time Checkov reads it.
Incorrect — Backwards: scanning raw HCL is the credential-free option, and producing a plan is the step that needs the provider and access to state.
Incorrect — Checkov reports findings and can record suppressions, but it never edits your Terraform.
Incorrect — A plan only describes what Terraform manages, so console-made resources stay invisible until a live audit goes looking for them.
02At 2 a.m. an engineer opens port 22 to 0.0.0.0/0 by hand in the cloud console to unblock an incident, and never reverts it. Which check in this lesson is built to notice?
Incorrect — The rule exists, but no Terraform file changed, so the scan reads clean text and passes.
Incorrect — Test runs build their own throwaway state and never touch your production state, so this change is invisible to them.
Correct — it asks the running cloud what is true right now, whoever made the change and however they made it.
Incorrect — Terratest builds a fresh copy in a sandbox account and never inspects the account where the change happened.
03A nightly Terratest job prints panic: test timed out after 10m0s followed by goroutine stacks, then exits non-zero. The module under test creates a load balancer, a NAT gateway and 14 other resources. What is the state of the world, and what do you do first?
Incorrect — A test timeout panics from a watchdog goroutine and kills the process, so the test's deferred cleanup never gets to run.
Incorrect — Terraform has no rollback: whatever the apply created before the timeout exists and is recorded in the state file.
Incorrect — Nothing retries. The timeout is fatal, the process is gone, and no cleanup happens.
Correct — this is the classic leak, which is why -timeout 30m and a scheduled cleanup of the test account are not optional extras.

Try this

Run checkov -d . --compact --quiet 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: your plan file is a secrets file. 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