TT Lab
Get started
Learn Learning paths Courses

Terraform in Practice

Checking by Hand After Every Module Change, Until One Slipped Through

Continue in TT Lab

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.

Notes

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.