Checking by Hand After Every Module Change, Until One Slipped Through
Goal
You build a module in a shape that can be tested, write a cheap plan-only test and an expensive actually-creating test, chain runs, test that what should be blocked is blocked, see what message a failure produces, check only values with a mock, and then bundle it all into one line for CI to call.
Why it matters
A reusable module gets scarier to change the more places call it. That is because people must check 'where does it break if I change this value' every time. If you attach tests, that check becomes a single command, and from then on you can change the module. What matters is the sense of using tests in two kinds — a plan-only test is fast and cheap, so it can cover everything where looking at values is enough, such as naming rules, conditional branches, and defaults, while an actually-creating test is slow but is the only way to confirm 'does that thing really come out that way.' If you add a test that checks 'is what should be blocked actually blocked,' nothing passes silently when you fix a validation wrongly. Finally, all of this must connect to a single CI exit code to have value.
Steps
- In
/root/tfa-test/main.tf, acceptvar.env(validation allowing only dev, stage, and prod) andvar.replicas(default 1, allowing only 1 to 10), put alocal_file.confthat writes the two linesname=andreplicas=toout/app-<env>.conf(where the placeholder is the environment name), and declare three outputsname,conf_path, andreplicas. Init and check withtofu validate. - In
/root/tfa-test/tests/basic.tftest.hcl, putenv = "dev"in file-levelvariables, and inrun "name_is_prefixed"withcommand = plan, assert that the outputnameisapp-dev. Runtofu testfiltered to only this file and save the output to/root/tfa-test/results/basic.txt. - In
/root/tfa-test/tests/apply.tftest.hcl, putrun "file_is_written"withcommand = applyusingenv = "stage"andreplicas = 3, and assert that the contents offile(output.conf_path)are exactly the two linesname=app-stageandreplicas=3. Save the output of running only this file to/root/tfa-test/results/apply.txt. - In
/root/tfa-test/tests/chain.tftest.hcl, putrun "make_dev"withcommand = applyandrun "reuse_previous_output"withcommand = plan, which decides its values by referencing that run's output. The second run asserts that the outputnameisapp-prod. Save the output of running only this file to/root/tfa-test/results/chain.txt. - In
/root/tfa-test/tests/validation.tftest.hcl, putrun "bad_env_is_rejected"running withenv = "qa"andrun "too_many_replicas_is_rejected"running withreplicas = 99, and declare withexpect_failuresin each that it is normal for that variable's validation to block. Save the output of running only this file to/root/tfa-test/results/validation.txt. - In
/root/tfa-test/tests/fail.tftest.hcl, put a deliberately wrong assertion (that the outputnameisapp-nope) and runtofu test. Save the output to/root/tfa-test/results/fail.txtand the exit code on one line in/root/tfa-test/results/fail.rc. Then move that file to/root/tfa-test/broken/fail.tftest.hclso that the test suite passes again. - In
/root/tfa-test/tests/mock.tftest.hcl, putmock_provider "local" {}and, inrun "mocked_apply"withcommand = applyandenv = "prod", assert that the outputnameisapp-prod. Save the output of running only this file to/root/tfa-test/results/mock.txt. - Create
/root/tfa-test/ci.sh. It must change into the directory it lives in, runtofu init -input=false(ending with 2 if it fails), then runtofu test, leave the output in/root/tfa-test/results/ci.txt, and return the test's exit code as it is. After creating it, run it once and check that it ends with 0.
Notes
- The Pod has OpenTofu 1.9.0 and a local provider mirror, so it runs without the internet. tofu test works offline as is.
- Test files are looked up in the current directory or the tests directory. To run only a particular file, use the filter option.
- A run that runs with apply actually creates, and the tool deletes it when it finishes. Check out/ to see whether any leftovers from the tests remain.
- Common mistake: a CI script swallowing the test's exit code. A pipeline that passes with a green light is worse than having no tests.
- Command: test · Command: validate · Input Variables
Build the target to attach tests to
In /root/tfa-test/main.tf, accept var.env (validation allowing only dev, stage, and prod) and var.replicas (default 1, allowing only 1 to 10), put a local_file.conf that writes the two lines name= and replicas= to out/app-<env>.conf (where the placeholder is the environment name), and declare three outputs name, conf_path, and replicas. Init and check with tofu validate.
To attach tests, it first has to be in a testable shape — you can write assertions only if there is a place to put values in from outside (variables) and a place to take results out (outputs). The validation will become something you test later with expect_failures.
A cheap test that only plans
In /root/tfa-test/tests/basic.tftest.hcl, put env = "dev" in file-level variables, and in run "name_is_prefixed" with command = plan, assert that the output name is app-dev. Run tofu test filtered to only this file and save the output to /root/tfa-test/results/basic.txt.
Test files are looked up in the current directory or the tests directory. If the command of a run block is plan, it only plans and asserts without creating anything, so it is fast — write everything where looking at values is enough, such as naming rules and conditional branches, this way.
An expensive test that actually creates and checks
In /root/tfa-test/tests/apply.tftest.hcl, put run "file_is_written" with command = apply using env = "stage" and replicas = 3, and assert that the contents of file(output.conf_path) are exactly the two lines name=app-stage and replicas=3. Save the output of running only this file to /root/tfa-test/results/apply.txt.
An apply test really creates, and the tool deletes it on its own when it finishes. So it is not cheap, but 'does that file really come to exist with that content' can be confirmed only this way. After the test finishes, open out/ and see what remained.
A later test uses the result of an earlier test
In /root/tfa-test/tests/chain.tftest.hcl, put run "make_dev" with command = apply and run "reuse_previous_output" with command = plan, which decides its values by referencing that run's output. The second run asserts that the output name is app-prod. Save the output of running only this file to /root/tfa-test/results/chain.txt.
You can reference an earlier run's output by the run name. In practice, tests whose steps are connected, like 'first create the network, then plan the app with its ID,' are written this way. The runs in a file proceed in order from top to bottom.
Test that what should be blocked is blocked
In /root/tfa-test/tests/validation.tftest.hcl, put run "bad_env_is_rejected" running with env = "qa" and run "too_many_replicas_is_rejected" running with replicas = 99, and declare with expect_failures in each that it is normal for that variable's validation to block. Save the output of running only this file to /root/tfa-test/results/validation.txt.
expect_failures is a device for saying in reverse 'this run passes only if it fails.' If you build a blocking device and do not check that it really blocks, nobody will know even if you fix the condition wrongly one day. In the list, you write the addresses of the targets that must fail.
See what a failing test shows
In /root/tfa-test/tests/fail.tftest.hcl, put a deliberately wrong assertion (that the output name is app-nope) and run tofu test. Save the output to /root/tfa-test/results/fail.txt and the exit code on one line in /root/tfa-test/results/fail.rc. Then move that file to /root/tfa-test/broken/fail.tftest.hcl so that the test suite passes again.
What the failure message shows is what matters — it shows together 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. The exit code is the only signal CI reads.
Imitate the provider and test only values
In /root/tfa-test/tests/mock.tftest.hcl, put mock_provider "local" {} and, in run "mocked_apply" with command = apply and env = "prod", assert that the output name is app-prod. Save the output of running only this file to /root/tfa-test/results/mock.txt.
A mock provider returns plausible values without actually creating anything. It is very valuable when testing a provider that is slow or costs money. However, since it is an imitation, it cannot confirm 'does that file really come to exist' — its role differs from the real apply test in step 3.
Bundle it into one line for CI to call
Create /root/tfa-test/ci.sh. It must change into the directory it lives in, run tofu init -input=false (ending with 2 if it fails), then run tofu test, leave the output in /root/tfa-test/results/ci.txt, and return the test's exit code as it is. After creating it, run it once and check that it ends with 0.
The only signal CI reads is the exit code. If the test failed but the script ends with 0, the pipeline passes with a green light — worse than having no tests. The grader runs this script twice on a copy. Once as it is now, and once with a failing test slipped in.