How does Terraform's native `terraform test` framework work — where do the test files live, and what does a `run` block do?
answer
- a test language, not a Go harness
- files are discovered in tests/ too
- runs execute in order, sharing state
- the default command is not plan
- cleanup happens per file, and can fail
basics
~20 sTests are .tftest.hcl files in the module directory or a tests/ subdirectory. Each run block executes a plan or an apply against the module and evaluates assert conditions. Apply runs create real infrastructure, which Terraform destroys when the file finishes.
solid answer
~50 sYou write `.tftest.hcl` files, which Terraform discovers in the module directory and in the `tests` directory, then execute them with `terraform test` against an initialized module. A file is a sequence of `run` blocks executed in order against shared state, so each one builds on the last. A `run` sets `variables` for that case and specifies `command = plan` to evaluate without provisioning or `command = apply`, the default, to actually provision. Inside it, `assert` blocks pair a `condition` expression with an `error_message`; conditions can reference the module's outputs and the attributes of resources under test. A later run can consume an earlier one's output via `run.<name>.<output>`. The important operational fact: with a real provider, apply runs create real infrastructure and cost real money — Terraform destroys what it created at the end of the file, in reverse order, and warns you if the cleanup fails.
code
hcl · 25 linesvariables {
bucket_prefix = "acme-test"
}
run "naming_is_composed" {
command = plan
variables {
environment = "dev"
}
assert {
condition = aws_s3_bucket.this.bucket == "acme-test-dev"
error_message = "bucket name was not composed from prefix and environment"
}
}
run "bucket_really_exists" {
command = apply
assert {
condition = output.bucket_arn != ""
error_message = "module did not export an ARN after apply"
}
}go deeper
Know that Terraform has a built-in test command, that tests are .tftest.hcl files containing run blocks with assert conditions, and that the module must be initialized first.
Explain that runs execute in order against shared state, that command chooses plan or apply with apply as the default, and how a later run reads run.<name>.<output> from an earlier one.
Bring the operational reality: real providers mean real resources and real cost, cleanup is per file and can leave orphans, and concurrent suites collide on globally unique names unless a setup module randomizes them.
Own the cost and isolation model — which account the suite runs in, who pays for orphaned resources, and where a native test suite stops being enough and a behavioural integration test earns its keep.
## Where tests live and how they run Terraform's test framework, generally available since Terraform 1.6, is written in HCL rather than in a general-purpose language. Test files use the `.tftest.hcl` extension (a JSON variant also exists) and are discovered in two places: the module's own directory, and a `tests` subdirectory beneath it. `terraform test` runs them; the directory must be initialized first, because the framework drives the real Terraform workflow and needs providers installed. A test file is a *sequence*, not a set. Its `run` blocks execute top to bottom against one shared state for that file, so run 2 sees whatever run 1 created. Files are independent of one another. ## Anatomy of a run block ```hcl variables { bucket_prefix = "acme-test" } run "plan_is_valid" { command = plan variables { environment = "dev" } assert { condition = aws_s3_bucket.this.bucket == "acme-test-dev" error_message = "bucket name was not composed from prefix and environment" } } ``` The pieces: - **`command`** — `plan` or `apply`. It defaults to `apply`. A plan run evaluates the configuration and lets you assert on planned values without creating anything; an apply run provisions for real and lets you assert on values that are only known after creation. - **`variables`** — set at file level as defaults and overridden per run. This is how one module gets exercised across several input combinations. - **`assert`** — a `condition` that must evaluate true and an `error_message` printed when it does not. Conditions may reference the module's outputs and the resources of the configuration under test. - **`expect_failures`** — the inverse assertion: you list the checkable objects you expect to fail, and the run passes only if they do. This is how you test that bad input is actually rejected. - **`module`** — points a run at a different module than the one under test, typically a small helper under `tests/setup` that provisions prerequisites or generates a random suffix. Across runs, `run.<name>.<output_name>` reads an earlier run's output, which is how you apply a module and then assert against it, or feed a created resource's id into a later case. ## The part that surprises people By default `terraform test` uses your **real providers**. An apply run creates real cloud resources with real cost and real blast radius. That is deliberate — the framework's headline value is that it can verify a module end to end — but it means test credentials, an isolated account, and unique naming matter enormously. Two developers running the same suite concurrently against the same account will collide on any globally unique name, which is why setup modules that generate a random suffix are a common pattern. Cleanup is automatic but not guaranteed: when a file finishes, Terraform destroys the objects created by its runs, in reverse order. If destroy fails — a dependency it cannot delete, a credential expiry, an interrupted run — Terraform reports which objects it could not clean up, and someone has to go and remove them. Budget for orphan cleanup in any real adoption. ## Useful flags `terraform test -filter=<file>` restricts execution to specific test files; `-verbose` prints the plan or state for each run so you can see what an assertion was looking at. A non-zero exit status on failure is what makes it usable as a pipeline step. ## What it replaces and what it does not Before this existed, module testing meant a Go harness — Terratest and similar — that shelled out to Terraform, applied a fixture, made assertions from Go, and deferred a destroy. That approach is still the one that can reach *past* Terraform: HTTP-request the endpoint you just created, SSH to the box, poll until an ASG is healthy. The native framework asserts on plan values, state and outputs; it does not make arbitrary network calls to prove the infrastructure actually works. So the honest positioning is: `.tftest.hcl` covers module logic — naming, conditional resources, counts, output wiring, input rejection — in the same language as the module and with no extra runtime, and it can do so cheaply once you introduce mocked providers. Behavioural verification against a live system remains the job of a Go-style integration test. ## Answering it well Name the file extension and discovery locations, describe the run block as an ordered plan-or-apply step with assertions, and volunteer the operational catch — that the default is `apply`, that it creates real infrastructure, and that cleanup happens per file and can fail. The last point is what separates someone who has run a suite from someone who has read the documentation.
- How would you test that your module correctly rejects a bad input value?Use `expect_failures` in a run block. You supply the offending input and list the checkable objects you expect to fail; the run passes only when those failures actually occur. It is the inverse of an `assert`, and it is how you prove your guardrails fire rather than only proving the happy path works.
- Two engineers run the same test suite against the same AWS account at the same time and both fail on a name conflict. How do you fix that?Stop using fixed names. A common pattern is a small setup module invoked from a run block that generates a random suffix, with the module under test taking that suffix as an input. Combine it with an account or region dedicated to testing so a collision or an orphan never touches anything that matters.
- When would you still write a Go-based integration test instead of a .tftest.hcl file?When the assertion has to reach past Terraform — issue an HTTP request to the endpoint you just created, connect to the database, poll until instances report healthy, or exercise a failover. The native framework asserts on planned values, state and outputs; a Go harness can apply the module and then behave like a client of the thing it built.
saying these in an interview costs you the question
- Assumes terraform test simulates everything and creates nothing
- Thinks run blocks are independent of each other
- Believes the default command is plan
- Expects Terraform to clean up across files or after a crash
- Confuses the framework with a Go integration harness