Asserting a Plan Without Creating Anything: Your First .tftest.hcl
Your team ships a change to the security-group module and a reviewer asks the obvious question: does this configuration ever open SSH to the world? Before Terraform 1.6 your honest answer was "I read the diff." Terratest could have proven it, but that meant real cloud resources, real credentials, and a coffee break while a security group converged. Since 1.6 the answer is a test file that runs in seconds and never touches a cloud account.
The layout is the first thing to internalize. Test files carry the .tftest.hcl extension and live in a tests/ subdirectory beside the configuration they verify:
modules/network/
├── main.tf
├── variables.tf
├── outputs.tf
└── tests/
└── security_group.tftest.hcl
Run terraform test from the module root and it discovers every .tftest.hcl file in that tests/ directory, executing the run blocks inside each one. You never point a test at a module — you drop the file next to the module, and the directory convention does the wiring. The file inherits the module under test, its provider requirements, and its variable definitions.
A run block is one test step. Here is a real one:
# tests/security_group.tftest.hcl
run "ssh_is_closed" {
command = plan
variables {
name = "web"
cidr_block = "10.0.0.0/16"
}
assert {
condition = length(aws_security_group_rule.ingress) == 2
error_message = "expected exactly two ingress rules: HTTP and HTTPS"
}
assert {
condition = alltrue([for r in aws_security_group_rule.ingress : r.from_port != 22])
error_message = "no ingress rule may open port 22"
}
}
Read it piece by piece. command = plan tells Terraform to build a plan against the module and evaluate every assert against the planned graph — command = plan is the whole trick: the real plan engine, the real provider schema, zero cloud API calls, zero credentials, zero cost. The variables block feeds input variables, which matters more than it looks: you exercise the module through its real interface, the same variables a root module would pass, so the test breaks exactly when the plumbing breaks. Each assert is a boolean over planned values; when one is false, Terraform prints your error_message — the string you will be reading in a CI log while a reviewer waits.
Running the suite prints one line per run block and exits non-zero on failure:
tests/security_group.tftest.hcl... in progress
run "ssh_is_closed"... pass
tests/security_group.tftest.hcl... tearing down
tests/security_group.tftest.hcl... pass
Success! 1 passed, 0 failed.
That exit code is the integration point: any CI shell step turns red without log parsing. While iterating locally, terraform test -filter=tests/security_group.tftest.hcl narrows execution to one file.
One warning before you copy this file into your repo: command defaults to apply. A run block with no command argument creates real infrastructure the first time CI runs your suite. Interviewers ask for that default precisely because it separates people who have run terraform test from people who have only read the release announcement.
There is one more crack in the plan-mode foundation. Add an assert on aws_security_group_rule.ingress[0].id and the run errors: identifiers are computed by the provider and arrive as unknown placeholders during a plan, so the condition cannot be evaluated. Mocking Providers in Tests repairs exactly that gap, which is why mocks and plan-mode tests ship as a pair. First, though, you need to understand what a run block does to state — that is the next section.
One File, Many Steps: How Run Blocks Share State and Choose Commands
A single run block proves one condition. Real modules need lifecycle tests: create the fixture, check it, change an input, check the change, tear down. Inside one test file, run blocks form exactly that sequence.
The command argument takes three values. plan evaluates a plan without changing anything. apply provisions for real and is the default when you omit the argument. destroy — added in Terraform 1.7 — tears down whatever the current test state holds. Runs execute top to bottom, and each run builds on the state left by the previous run in the same file. That shared state is what turns a file into a scripted lifecycle:
# tests/database.tftest.hcl
variables {
environment = "test"
}
run "baseline" {
command = apply
assert {
condition = aws_db_instance.main.instance_class == "db.t3.micro"
error_message = "the test environment must stay on db.t3.micro"
}
}
run "scale_up_plans_a_change" {
command = plan
variables {
instance_class = "db.m5.large"
}
assert {
condition = aws_db_instance.main.instance_class == "db.m5.large"
error_message = "the variable override never reached the resource"
}
}
Two mechanisms are visible here. First, the file-level variables block sets defaults for every run in the file, and a run-level variables block overrides individual keys on top of those defaults — merge, not replace. Second, scale_up_plans_a_change refreshes the database that baseline created and plans the modification against it, so the assert reads a planned value while the cloud stays untouched. Had command stayed at apply, the run would actually resize a database.
An important boundary: test state is never your configured backend's state. Each test file gets state that is local to the invocation and discarded when the file finishes. Tests cannot corrupt an environment's real state file, and two CI jobs running the same suite never contend on a state lock — provided the applies inside them target real clouds deliberately, or not at all. A non-mock provider under command = apply really does create resources, so the cheap files in your suite should say command = plan out loud.
Failure semantics deserve their own paragraph because they shape how you organize files. When an assert condition is false, Terraform fails the run and skips every remaining run block in that file — later runs may depend on state the failed run was supposed to produce, so continuing would produce misleading errors. The practical rule: put tightly coupled lifecycle steps (create, mutate, destroy) in one file, and independent scenarios in separate files. Otherwise one red condition masks five others, and terraform test -filter becomes your triage tool instead of your iteration tool.
The destroy command rounds out the lifecycle. A final run with command = destroy lets you assert teardown behavior — for example, that a fixture with a deletion guard still tears down when the guard variable is flipped. Terraform 1.7 also added a parallel argument so independent runs can execute concurrently against the same state snapshot, but sequential remains the default and the safe assumption.
Interview angle: "Why does command default to apply?" is a fair question with a real answer. The framework's ancestry was end-to-end verification — spin it up, check it, tear it down — and plan-mode unit tests arrived later as the optimization. Being able to defend the default while explaining why your suite overrides it to plan wherever possible shows you understand both the history and the cost model.
Wiring terraform test Into CI Without Surprises
The suite only protects you if CI runs it, and CI will run a different Terraform than your laptop unless you pin things. Three facts make the difference between a gate and a decoration.
Version floor first: HCL test files require Terraform 1.6 or newer. The terraform test subcommand existed since 0.15, but only as a JSON-only apply runner — the framework in this topic is a 1.6 feature. A CI image still on 1.4 dies with a parse error on your first .tftest.hcl, so pin the version explicitly:
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.8
- run: terraform fmt -check -recursive
- run: terraform init -backend=false && terraform validate
- run: terraform test
working-directory: modules/network
Second fact: bare terraform test runs every test file in the tests/ directory. That default is the point — one command, one exit code, the whole suite on every pull request. -filter is a local iteration tool; using it in CI silently narrows your gate to whichever file someone remembered to list. If a team convention relocates tests, -test-directory changes where the runner looks, but resist the temptation: tests/ beside the module is what keeps discovery predictable across dozens of modules.
Third fact: a suite of plan-mode runs needs no backend and no credentials. terraform init -backend=false is enough for validate, and mocked or plan-only tests never prompt for cloud access. That property matters more than it seems: fork pull requests never receive repository secrets, so credentials-dependent jobs skip or fail on exactly the contributions that need review most. A credential-free terraform test gate still runs there.
When the gate goes red, the output tells you which run block and which error_message fired, and terraform test -json emits machine-readable events if you want PR annotations. The failures you will actually meet follow a pattern:
| Symptom in CI | Cause | Fix |
|---|---|---|
Unsupported argument "command" in run block |
Terraform older than 1.6 | pin terraform_version: 1.9.x in setup-terraform |
| Job hangs prompting for credentials | run blocks defaulting to apply against the real provider | set command = plan, or add a mock_provider |
| Assert errors because a value "will be known after apply" | computed attribute read during a plan | pin it with mocks or overrides in the next topic |
| First red hides five unrelated failures | remaining runs in the file were skipped | split independent scenarios into separate files |
| Local green, CI red on formatting | fmt -check never ran locally |
run the same three commands before push |
There is also a structural payoff from the earlier phases. If Multi-Cloud Design Tradeoffs pushed you toward three small per-cloud modules instead of one sprawling root, each module now carries its own tests/ directory and the CI job above fans out per directory — a shape you will formalize in Layering Tests in the Pipeline.
Interview framing: "How do you test Terraform?" is asked in almost every infrastructure loop. Walk the ladder rather than naming a tool: fmt, validate, terraform test with explicit plan-mode runs, mocks for unknown values, and Terratest for the scenarios that genuinely need real applies. The ladder is the answer; each tool is one rung.
Quiz
1. You write a run block in a .tftest.hcl file and omit the command argument. What does terraform test do when it reaches that block?
2. Where does terraform test look for test files when you run it from a module's root directory?
3. A run with command = plan asserts aws_instance.web.id == "i-0123abcd". The run errors instead of passing. Why?
4. In one test file, run "b" follows run "a", and an assert inside run "a" fails. What does terraform test do next?
Flashcards
Question
What is the default value of the command argument in a run block, and why does that default matter?
Click to reveal answer
Answer
The default is apply — a run block without command creates real infrastructure on the first execution. Suites that must stay free of credentials, cost, and cloud side effects set command = plan explicitly, everywhere.
Question
Where do .tftest.hcl files live, and which command runs them?
Click to reveal answer
Answer
In a tests/ subdirectory beside the module under test. terraform test, run from the module root, executes every .tftest.hcl file in that directory; -filter narrows to one file and -test-directory relocates the whole suite.
Question
How do run blocks within a single test file relate to each other and to state?
Click to reveal answer
Answer
They execute sequentially, and each run builds on the state left by the previous run — so a file models a lifecycle (create, mutate, check, destroy). That state is local to the test invocation and never touches the configured backend.
Question
What happens to the rest of a test file when one assert condition evaluates to false?
Click to reveal answer
Answer
The run fails with your error_message, the remaining run blocks in that file are skipped (they may depend on the failed run's state), and terraform test exits non-zero. Independent scenarios belong in separate files so one red does not mask others.
Question
How do the file-level variables block and a run-level variables block interact?
Click to reveal answer
Answer
The file-level variables block supplies default input variable values for every run in the file; a run-level variables block overrides individual keys on top of those defaults (merge, not replace). Tests therefore exercise the module through its real variable interface.
Revision Notes
Key Takeaways
- 1. Test files are .tftest.hcl files in a tests/ subdirectory beside the module; terraform test executes every file there and returns a CI-usable exit code
- 2. command defaults to apply — say command = plan out loud in every run that must stay free of credentials, cost, and cloud side effects
- 3. Run blocks execute sequentially and share test-local state, so one file models a lifecycle; that state never reads or writes your configured backend
- 4. A failed assert skips the rest of that file by design, so independent scenarios must live in separate files or one red masks five others
- 5. Terraform 1.6 is the version floor for HCL tests; pin it in CI or the suite dies with a parse error on older images
Interview Tips
- • "How do you test Terraform?" — answer with the ladder: fmt, validate, terraform test with explicit plan-mode runs, mocks for unknown values, Terratest for real applies; the ladder beats naming a single tool
- • "What is the default command in a run block?" — apply, and explain the consequence: an unmodified test suite provisions real infrastructure, which is why plan-mode tests state command explicitly
- • "Why split scenarios across test files?" — one failed assert skips the remaining runs in the file because they depend on its state; separate files keep failures independent and readable
- • "Can you assert on a computed attribute in a plan test?" — no: the value is an unknown placeholder until apply; name mock_provider or override_resource as the fix to show you know the next layer
Cheat Sheet
The Native terraform test Framework
File layout and commands:
| Thing | Shape |
|---|---|
| Test file | tests/<name>.tftest.hcl beside the module under test |
| Run the suite | terraform test (runs every test file in tests/) |
| Run one file | terraform test -filter=tests/security_group.tftest.hcl |
| Machine output | terraform test -json |
| Version floor | Terraform 1.6+ (HCL tests did not exist before) |
Run block anatomy:
run "scenario_name" {
command = plan # plan | apply (default!) | destroy (1.7+)
variables { # overrides on top of file-level variables
instance_class = "db.m5.large"
}
assert {
condition = aws_db_instance.main.instance_class == "db.m5.large"
error_message = "override never reached the resource"
}
}
Semantics that bite:
commanddefaults to apply — bare run blocks create real infrastructure.- Runs execute in order; each builds on the previous run's test-local state (never your backend).
- A false assert fails the run and skips the remaining runs in that file; exit code is 1.
- Plan-mode runs can only assert known values; computed attributes need mocks or overrides (next topic).
Minimal CI gate:
- run: terraform fmt -check -recursive
- run: terraform init -backend=false && terraform validate
- run: terraform test # working-directory: modules/<name>