Skip to main content
Terraform · lesson 17 of 17
Terraform advanced Lesson 17 of 17

Also part of: Software Testing

terraform test: Catching a Wrong Assertion, Not Just a Wrong Plan

Terraform's test runner checks real plans and applies from .tftest.hcl files. It catches a bug in the test, a deleted validation, and mocks a provider.

By SkillByExample · Updated

terraform testing test mock_provider ci

terraform plan tells you what a config would do. It doesn’t tell you whether that’s correct, for example whether prod really gets more capacity than dev. terraform test runs plans and applies from .tftest.hcl files and checks assertions after each one. Examples use Terraform 1.16.5 and hashicorp/local 2.9.1.

A config with environment logic

terraform {
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.9"
    }
  }
}

variable "environment" {
  type    = string
  default = "dev"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of: dev, staging, prod."
  }
}

locals {
  max_connections = var.environment == "prod" ? 100 : 10
}

resource "local_file" "config" {
  filename = "${path.module}/generated/app.conf"
  content  = "environment=${var.environment}\nmax_connections=${local.max_connections}\n"
}

output "max_connections" {
  value = local.max_connections
}

output "config_path" {
  value = local_file.config.filename
}

The rule under test is that prod gets 100 connections and everything else gets 10.

A test with a bug in the test

tests/config.tftest.hcl:

run "dev_defaults_are_low" {
  command = plan

  variables {
    environment = "dev"
  }

  assert {
    condition     = output.max_connections == 10
    error_message = "dev environment should cap connections at 10, got ${output.max_connections}"
  }
}

run "prod_has_higher_ceiling" {
  command = apply

  variables {
    environment = "prod"
  }

  assert {
    condition     = output.max_connections == 999
    error_message = "prod environment should cap connections at 999, got ${output.max_connections}"
  }
}
$ terraform test -no-color
tests/config.tftest.hcl... in progress
  run "dev_defaults_are_low"... pass
  run "prod_has_higher_ceiling"... fail

Error: Test assertion failed

  on tests/config.tftest.hcl line 22, in run "prod_has_higher_ceiling":
  22:     condition     = output.max_connections == 999
    ├────────────────
    │ Diff:
    │ --- actual
    │ +++ expected
    │ - 100
    │ + 999


prod environment should cap connections at 999, got 100
tests/config.tftest.hcl... tearing down
tests/config.tftest.hcl... fail

Failure! 1 passed, 1 failed.

The config was right all along. The 999 is a typo in the test. A test failure doesn’t say which side is wrong, only that the code and the test disagree. The diff shows actual (100) against expected (999), and you decide which one to fix. Here it’s the test.

Fixing the assertion, not the config

Change 999 to 100 in both places:

$ terraform test -no-color
tests/config.tftest.hcl... in progress
  run "dev_defaults_are_low"... pass
  run "prod_has_higher_ceiling"... pass
tests/config.tftest.hcl... tearing down
tests/config.tftest.hcl... pass

Success! 2 passed, 0 failed.

dev_defaults_are_low only planned, so nothing was created. prod_has_higher_ceiling applied: it really wrote generated/app.conf, and “tearing down” deleted it at the end of the file. Add -verbose to see what each run planned or applied:

$ terraform test -verbose -no-color
tests/config.tftest.hcl... in progress
  run "dev_defaults_are_low"... pass

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # local_file.config will be created
  + resource "local_file" "config" {
      + content              = <<-EOT
            environment=dev
            max_connections=10
        EOT
      + content_base64sha256 = (known after apply)
      + content_base64sha512 = (known after apply)
      + content_md5          = (known after apply)
      + content_sha1         = (known after apply)
      + content_sha256       = (known after apply)
      + content_sha512       = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0777"
      + filename             = "./generated/app.conf"
      + id                   = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + config_path     = "./generated/app.conf"
  + max_connections = 10

  run "prod_has_higher_ceiling"... pass

# local_file.config:
resource "local_file" "config" {
    content              = <<-EOT
        environment=prod
        max_connections=100
    EOT
    content_base64sha256 = "CsjBNyL6GW+B+kOPmasOtv6UVZKVceTBxvU+ivIsqCk="
    content_base64sha512 = "JYPCiU/jvpIqY4mTF4tlEuDZYL0P2Ro7K1XanT70+0DgsLHxqanXqiQEYtfWhA1uQtY49JKpkLrtv/LeplvGkA=="
    content_md5          = "3c60c5af0dddc6dcb0026e7eeb387f8d"
    content_sha1         = "a3d2990d696e1fc81f3e72d5a25ef0fef13bd3c8"
    content_sha256       = "0ac8c13722fa196f81fa438f99ab0eb6fe9455929571e4c1c6f53e8af22ca829"
    content_sha512       = "2583c2894fe3be922a638993178b6512e0d960bd0fd91a3b2b55da9d3ef4fb40e0b0b1f1a9a9d7aa240462d7d6840d6e42d638f492a990baedbff2dea65bc690"
    directory_permission = "0777"
    file_permission      = "0777"
    filename             = "./generated/app.conf"
    id                   = "a3d2990d696e1fc81f3e72d5a25ef0fef13bd3c8"
}


Outputs:

config_path = "./generated/app.conf"
max_connections = 100

tests/config.tftest.hcl... tearing down
tests/config.tftest.hcl... pass

Success! 2 passed, 0 failed.

The plan run prints a plan, and the apply run prints the resulting state, with real hashes, because a real file was written.

Testing that bad input is rejected

tests/validation.tftest.hcl:

run "rejects_unknown_environment" {
  command = plan

  variables {
    environment = "qa"
  }

  expect_failures = [
    var.environment,
  ]
}
$ terraform test -no-color
tests/config.tftest.hcl... in progress
  run "dev_defaults_are_low"... pass
  run "prod_has_higher_ceiling"... pass
tests/config.tftest.hcl... tearing down
tests/config.tftest.hcl... pass
tests/validation.tftest.hcl... in progress
  run "rejects_unknown_environment"... pass
tests/validation.tftest.hcl... tearing down
tests/validation.tftest.hcl... pass

Success! 3 passed, 0 failed.

expect_failures reverses the logic: this run passes because the validation rule rejected qa. The test now pins the validation rule in place. If someone loosens or deletes it, this run turns red (Practice 3).

Mocking the provider

tests/mocked.tftest.hcl replaces the real local provider with fake responses:

mock_provider "local" {
  mock_resource "local_file" {
    defaults = {
      id = "mock-file-id"
    }
  }
}

run "prod_apply_with_mocked_provider" {
  command = apply

  variables {
    environment = "prod"
  }

  assert {
    condition     = local_file.config.id == "mock-file-id"
    error_message = "expected the mocked id, got ${local_file.config.id}"
  }
}
$ terraform test -filter=tests/mocked.tftest.hcl -verbose -no-color
tests/mocked.tftest.hcl... in progress
  run "prod_apply_with_mocked_provider"... pass

# local_file.config:
resource "local_file" "config" {
    content              = <<-EOT
        environment=prod
        max_connections=100
    EOT
    content_base64sha256 = "k1dem4i9"
    content_base64sha512 = "wcgds2t7"
    content_md5          = "3dd292qi"
    content_sha1         = "y7nnzi3i"
    content_sha256       = "11matojv"
    content_sha512       = "6bag87f1"
    directory_permission = "gg381xsb"
    file_permission      = "s4f5hzhf"
    filename             = "./generated/app.conf"
    id                   = "mock-file-id"
}


Outputs:

config_path = "./generated/app.conf"
max_connections = 100

tests/mocked.tftest.hcl... tearing down
tests/mocked.tftest.hcl... pass

Success! 1 passed, 0 failed.

This “apply” never called the provider. Values set in the config (content, filename) are real. Computed values are made up: id came from the mock’s defaults, and every other computed attribute is a random 8-character string that changes on each run. Even directory_permission is random, because it’s optional and computed. With a cloud provider, this is how you test module logic in CI without an account. The trade-off is that a mock can’t tell you whether the real API would accept the values.

Practice

1. Change the validation test’s input to "staging", a valid value, and run the whole suite.

Solution
$ terraform test -no-color
tests/config.tftest.hcl... in progress
  run "dev_defaults_are_low"... pass
  run "prod_has_higher_ceiling"... pass
tests/config.tftest.hcl... tearing down
tests/config.tftest.hcl... pass
tests/mocked.tftest.hcl... in progress
  run "prod_apply_with_mocked_provider"... pass
tests/mocked.tftest.hcl... tearing down
tests/mocked.tftest.hcl... pass
tests/validation.tftest.hcl... in progress
  run "rejects_unknown_environment"... fail

Error: Missing expected failure

  on tests/validation.tftest.hcl line 9, in run "rejects_unknown_environment":
   9:     var.environment,

The checkable object, var.environment, was expected to report an error but
did not.
tests/validation.tftest.hcl... tearing down
tests/validation.tftest.hcl... fail

Failure! 3 passed, 1 failed.

expect_failures is as strict about a check that unexpectedly passes as assert is about a false condition. Files run in alphabetical order, so mocked runs before validation.

2. In dev_defaults_are_low, add one assertion on output.config_path and another on local_file.config.content_md5 != "". Which one fails, and why?

Solution
  assert {
    condition     = output.config_path == "./generated/app.conf"
    error_message = "unexpected config path ${output.config_path}"
  }

  assert {
    condition     = local_file.config.content_md5 != ""
    error_message = "file should have a checksum"
  }
$ terraform test -filter=tests/config.tftest.hcl -no-color
tests/config.tftest.hcl... in progress
  run "dev_defaults_are_low"... fail

Error: Unknown condition value

  on tests/config.tftest.hcl line 19, in run "dev_defaults_are_low":
  19:     condition     = local_file.config.content_md5 != ""
    ├────────────────
    │ local_file.config.content_md5 is a string

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. Alternatively, if there is an override for this value,
you can make it available during the plan phase by setting `override_during =
plan` in the `override_` block.
  run "prod_has_higher_ceiling"... skip
tests/config.tftest.hcl... tearing down
tests/config.tftest.hcl... fail

Failure! 0 passed, 1 failed, 1 skipped.

The path is known at plan time, but the checksum only exists after the file is written. Change the run to command = apply and both pass (Success! 2 passed, 0 failed.). Note that the failure also caused the next run in the file to be skipped.

3. Delete the validation block from variable "environment" and run only the validation test.

Solution
$ terraform test -filter=tests/validation.tftest.hcl -no-color
tests/validation.tftest.hcl... in progress
  run "rejects_unknown_environment"... fail

Error: Missing expected failure

  on tests/validation.tftest.hcl line 9, in run "rejects_unknown_environment":
   9:     var.environment,

The checkable object, var.environment, was expected to report an error but
did not.
tests/validation.tftest.hcl... tearing down
tests/validation.tftest.hcl... fail

Failure! 0 passed, 1 failed.

The error is identical to Practice 1, but the cause is different. Here the rule that would reject qa is gone. In both cases the test catches the problem, which is why it’s worth having.

Frequently Asked Questions

Does terraform test need real cloud credentials?
It needs whatever the providers under test need, unless you mock them. A run with command = apply really creates and then destroys resources, so a cloud provider needs credentials. A mock_provider block (Terraform 1.7+) replaces the provider with fake responses, so plans and applies run with no account at all.
What's the difference between command = plan and command = apply in a run block?
command = plan only builds a plan and checks assertions against planned values, so nothing is created; assertions on values known only after apply fail with 'Unknown condition value'. command = apply creates real resources, checks assertions against them, and destroys them when the file's runs finish.
What does expect_failures test?
That a named checkable object, such as a variable with a validation block, a precondition or a postcondition, reports an error for the given input. The run passes only if that check fails, and fails with 'Missing expected failure' if the check unexpectedly passes or no longer exists.
Do run blocks in one test file share state?
Yes. Within one .tftest.hcl file, each run starts from the state left by the previous run, and everything is torn down at the end of the file. Separate .tftest.hcl files never share state. If a run fails, the later runs in the same file are skipped.
How do I run only one test file?
terraform test -filter=tests/config.tftest.hcl runs just that file; repeat -filter to run several. Plain terraform test runs every .tftest.hcl file in the tests directory and the root directory, in alphabetical order.