OnCallReady

Lesson 13.53 · Terraform: State & Modules · 14 min read

Testing modules with terraform test

In plain words

Before a toy company sells a new toy car, it runs tests: does it roll straight, do the wheels stay on, does it refuse to work if you put the battery in backwards? They test with pretend kids first (cheap) and only sometimes with real ones in a playground (expensive, and you have to tidy up after).

terraform test does that for modules. Test files end in .tftest.hcl and contain run blocks. A command = plan run is the cheap pretend test: fast, creates nothing, but cannot check values only known after creation. A command = apply run really builds resources, checks them, and tears them down. assert blocks check conditions; expect_failures checks that bad input is correctly rejected.

The problem

Your network module is called by five teams. You change how subnets are built, it plans fine on your laptop with your inputs, you tag a release - and two teams get broken plans because their inputs take a path you never tried.

"It planned on my laptop" is not a test. Since Terraform 1.6 there is a native test framework, terraform test: you write test cases in HCL next to the module, and a CI job runs them on every change.

What you need to know already:

Where tests live

modules/network/
  main.tf
  variables.tf
  outputs.tf
  tests/
    network.tftest.hcl

Test files end in .tftest.hcl and live in the module directory or in a tests/ directory under it. terraform test runs every one of them.

Anatomy of a test file

# tests/network.tftest.hcl

variables {
  name          = "vnet-test"
  address_space = ["10.99.0.0/16"]
  subnets = {
    app  = { cidr = "10.99.1.0/24" }
    data = { cidr = "10.99.2.0/24" }
  }
}

run "creates_one_subnet_per_entry" {
  command = plan

  assert {
    condition     = length(azurerm_subnet.this) == 2
    error_message = "expected one subnet per entry in var.subnets"
  }

  assert {
    condition     = azurerm_subnet.this["app"].address_prefixes[0] == "10.99.1.0/24"
    error_message = "the app subnet got the wrong CIDR"
  }
}

run "rejects_an_invalid_cidr" {
  command = plan

  variables {
    subnets = {
      app = { cidr = "10.99.1.0/33" }
    }
  }

  expect_failures = [var.subnets]
}

The file in words:

The pieces:

variables { }        values for the module's input variables, for every run in the file
run "name" { }       one test step; runs in order, sharing state within the file
  command = plan     only plan - fast, creates nothing
  command = apply    (the default) really creates the resources; destroyed at the end
  variables { }      overrides for this run only
  assert { }         condition + error_message; every assert in a run is checked
  expect_failures    the run PASSES only if these things fail
                     (a variable's validation, a precondition, a check block)
  module { source }  run against a different module (e.g. an example that calls this one)

Inside an assert you can refer to everything in the module under test: resources (azurerm_subnet.this["app"]), outputs (output.subnet_ids), variables (var.name) - and outputs of earlier runs (run.setup.some_output).

Running it

terraform init first (to install providers), then terraform test:

# in the network module with tests/network.tftest.hcl (the testing mission)
terraform init
terraform test
tests/network.tftest.hcl... in progress
  run "creates_one_subnet_per_entry"... pass
  run "rejects_an_invalid_cidr"... pass
tests/network.tftest.hcl... pass

Success! 2 passed, 0 failed.

One line per run with pass or fail, one per file, and a total.

A failure prints the assertion's message in the usual error frame:

  run "creates_one_subnet_per_entry"... fail
╷
│ Error: Test assertion failed
│
│   on tests/network.tftest.hcl line 13, in run "creates_one_subnet_per_entry":
│   13:     condition     = length(azurerm_subnet.this) == 2
│
│ expected one subnet per entry in var.subnets
╵
tests/network.tftest.hcl... fail

Failure! 1 passed, 1 failed.

and the command exits non-zero, which is what makes a CI step fail.

Useful flags:

plan runs vs apply runs

A command = plan run is fast and free, but it can only check values known at plan time. Anything the provider computes - an id, an IP address, a generated name - is still unknown:

│ Error: Unknown condition value
│
│ Condition expression could not be evaluated at this time. This means you have
│ executed a `run` block with `command = plan` and one of the values your
│ condition depended on is not known until after the plan has been applied.
│ Either remove this value from your condition, or execute an `apply` command
│ from this `run` block.

A command = apply run really creates the infrastructure in the Azure account you are signed in to. It checks its asserts against real values, and at the end of the file destroys everything the file created ("tearing down").

That is the only way to test things like "the private endpoint (13.4) really gets an IP". It also costs money and time, and needs manual cleanup if a run is killed half-way. (In the lab, apply runs use the engine's computed values and create nothing in the simulated cloud - simulator.)

Terraform 1.7 added mocking: mock_provider "azurerm" {} in a test file makes a fake provider that invents the computed values, so apply-style tests run without any Azure account. The lab's test runner does not have it (simulator), but know that it exists.

What to test

Good module tests check behaviour callers rely on:

Not worth testing: that the provider does what its docs say (the azurerm provider has its own tests), or that a fixed value equals itself.

Where tests sit in the pipeline

fmt -check  ->  validate  ->  lint  ->  terraform test  ->  (for a module: release a tag)

fmt -check and validate you know (12.24). A linter is a tool that flags suspicious code that is still valid (for Terraform: tflint).

For modules, terraform test in CI on every PR is the gate before a new version is tagged. For root configurations, the plan itself is the test, and check blocks cover the rest.

Later (Ch 14): the full pre-merge pipeline, with tflint and security scanning.

What you can now do:

Why it helps

When you own a module other teams depend on, "it planned on my laptop" is not enough. terraform test in the module's CI on every PR catches the refactor that silently stopped creating one subnet per entry, or the validation that no longer rejects /33. Situations: a reviewer asks how you know a module change is safe to release as a minor version; your answer is the test suite plus a plan of the example. You will also hit "Unknown condition value" when a plan-only run asserts on an ID, and need to choose between an apply run (real cost) or a different assertion. Mocking (1.7+) and test structure are exam-relevant and good interview material.

FAQ

What is the difference between command = plan and command = apply in a test?

A plan run is fast, free and creates nothing, but it can only assert on values known at plan time; an ID or computed IP is unknown and gives "Unknown condition value". An apply run, the default, really creates resources in the account you are authenticated to, asserts on real values, and destroys everything at the end of the file. It costs time and money and needs cleanup if the run is killed.

What does expect_failures do?

It inverts the run: the run passes only if the listed checkable objects fail, such as a variable's validation, a precondition or a check block. expect_failures = [var.subnets] with an invalid CIDR proves the validation rejects it. Testing rejections matters because validation rules are part of a module's contract and easy to break during refactoring.

Can I test without cloud credentials?

Plan-only runs need the provider to initialise, which for azurerm usually still needs credentials or at least configuration. Terraform 1.7 added mocking: mock_provider "azurerm" {} returns fake computed values, so tests run without touching Azure. Mocks can also override specific resources' values. The lab's 1.9 test runner does not support it (simulator), but real Terraform does.

Where do test files go and how do I run just one?

Files end in .tftest.hcl and live in the module directory or in a tests/ subdirectory. terraform test runs them all, in order; runs within a file share state. -filter=tests/network.tftest.hcl runs one file, and -verbose prints each run's plan or state. The command exits non-zero on failure, which is what a CI step needs.

What should I not bother testing?

That the provider does what its documentation says, since azurerm has its own acceptance tests, and that literals equal themselves. Test the behaviour callers rely on: which resources a given input produces, that defaults are safe (no public access, TLS minimum), that invalid inputs are rejected, and that outputs have the documented shape.

In an interview Junior

How do you test a Terraform module?

With the native framework, terraform test (1.6+). Test files end in .tftest.hcl, in the module directory or tests/:

variables {
  subnets = { app = { cidr = "10.40.1.0/24" } }
}

run "creates_one_subnet_per_entry" {
  command = plan
  assert {
    condition     = length(azurerm_subnet.this) == 1
    error_message = "Expected one subnet per entry."
  }
}

run "rejects_bad_cidr" {
  command = plan
  variables { subnets = { app = { cidr = "10.40.1.0/33" } } }
  expect_failures = [var.subnets]
}

terraform test exits non-zero on failure, so CI runs it on every PR before a module version is tagged.

Also asked: What would you put in a test suite for a network module? · What is the difference between a plan run and an apply run in terraform test? · Where do Terraform tests fit in a CI job?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.