Infrastructure Code Deserves Tests Too
In one sentence
.tftest.hcl lets you write cheap tests (plan only) and expensive tests (actually creating things) in one syntax. The whole skill is the sense of how to mix the two.
Why this was needed — how a module becomes one you cannot change
When you first build a module, there is one place that calls it. You fix it, run it, and you are done. Half a year later, twelve places call it. Now every time you change a line, you have to check what happens to twelve places, and since there is no time for that, people choose another path — they do not change it. When a new requirement arrives, instead of changing the module they build a similar module next to it. That is how modules multiply, until nobody knows which is the latest.
Tests are the device that breaks this vicious cycle. They turn "where does it break if I change this value" into a single command. But testing infrastructure code has one more worry. What you can know only by actually creating it and what you can know just by looking at values are mixed together.
How it works
Test files use the .tftest.hcl extension and are looked up in the current directory or the tests directory. Inside a file, run blocks go in order.
variables { # 이 파일의 모든 run 이 쓰는 기본 입력
env = "dev"
}
run "name_is_prefixed" {
command = plan # 아무것도 만들지 않는다
assert {
condition = output.name == "app-dev"
error_message = "이름 규칙이 깨졌습니다"
}
}
The Korean text in this example says, in order: the default input used by every run in this file; it creates nothing; and the error message "the naming rule is broken."
The difference between command = plan and command = apply is the key. A test that only plans is fast and creates nothing, so it can cover everything where looking at values is enough, such as naming rules, conditional branches, defaults, and computed values. A test that actually creates is slow and uses resources, but "does that file really come to exist with that content" can be known only this way. When it finishes, the tool deletes what it created.
runs can be chained. A later run refers to an earlier run's output by name. Steps such as "first create the network, then plan the app with its ID" are expressed this way.
expect_failures is a device for saying it in reverse. It declares that this run passes only if it fails. It is used when you test whether a validation really blocks and whether bad input is really rejected. If you build a blocking device and do not verify it, nobody will know even if one day you fix the condition wrongly.
mock_provider imitates a provider. It returns plausible values without actually creating anything, so it is very valuable when testing a provider that is slow or costs money. In exchange, since it is an imitation, it cannot confirm "does it really come out that way." You must be clear that its role differs from a real apply test.
The message on failure is also worth looking at. It shows the line where the assertion was and what the value actually was at that time. So you do not need to repeat the value in error_message.
What it looks like in the field
The most common incident is a CI script swallowing the exit code. If the test failed but the script ends with 0, the pipeline passes with a green light. This is worse than having no tests — because it makes people believe it was checked when nobody checked. So once you have built the script, you must slip in one test that fails on purpose and see once whether the red light turns on.
The second is writing only expensive tests. If you write everything as apply, one round of tests gets long, and when it gets long, people do not run it. The practical balance is to move most things to plan and leave only the few that are truly necessary as actual creation.
The third is the leftovers that tests leave. The tool does delete what it created, but if a test dies midway, something may remain. If you run tests in a shared account, it is safer to put a separator in the names.
And one easily forgotten fact — test files are code too. They get reviewed, live in the same repository as the module, and are fixed together when the module is fixed. If the tests live somewhere else outside the module, half a year later you end up in a state where nobody runs them.
The fourth is not deciding what not to test. If you repeatedly test what the provider already guarantees (if you put in a value, that value goes in), only the number of tests grows and you gain nothing. What tests of infrastructure code should protect is the rules we made — naming rules, per-environment branching, inputs we do not allow, and connections between layers. What is tested is not the provider's behavior but our decisions.
Finally, if you remember that tests are a device that makes a module changeable, priorities settle. The first place to attach tests is the most-called module, and among those, the point where an incident recently happened. If you set a perfect coverage as the goal, you cannot start, but attaching a test to the one spot where an incident occurred is something you can do today.
What to do in the next lab
In /root/tfa-test, you build a module with two variables and three outputs and attach a plan-only test and an actually-creating test to it. You chain runs to use an earlier result as the next input, confirm with expect_failures that validation blocks, run a test that fails on purpose to see the message and exit code, check only the values with mock_provider, and then bundle everything into a script for CI to call and confirm that both directions (pass and fail) end correctly.