Test Terraform variable validation before a plan reaches a provider

John Burns

A Terraform variable declaration is often treated as a convenience for making a module reusable. It is also an input boundary. A value can have the right HCL syntax and still be unsuitable for the module: a replica count of zero, a fractional capacity, or a string where an API expects a number. If that condition is left to a resource argument or a provider API, the failure can arrive after Terraform has initialized providers, read state, or started to construct a larger plan.

This guide uses Terraform 1.15.9 to put a small variable contract under test before a module has any provider configuration. The completed check accepts the whole number 2, and treats 0 and 1.5 as expected validation failures. A separate negative run without an expected-failure declaration exits nonzero and identifies the variable rule that stopped it. The validation was performed in an isolated Linux AMD64 directory containing only the files shown here. It had no provider blocks, backend, cloud credentials, state, or resources, so the test did not create infrastructure.

Terraform 1.15.9 was released on 19 August 2026. The release is a reason to record the executable used for a test, not a reason to update a production workflow without its own compatibility review. Use the same Terraform version or compatible version range that your CI runner and deployment process use. A test is valuable only when it exercises the language and module behavior that a later plan will use.

Put the rule beside the variable

Start with the smallest module that expresses the policy. This example accepts only a whole number greater than or equal to one. It has an output solely so the successful test can make a concrete assertion; a real module would consume the value in a resource or a child-module argument.

terraform {
  required_version = "= 1.15.9"
}

variable "replica_count" {
  type = number

  validation {
    condition     = var.replica_count >= 1 && floor(var.replica_count) == var.replica_count
    error_message = "replica_count must be a whole number of at least 1."
  }
}

output "replica_count" {
  value = var.replica_count
}

Save the configuration as main.tf. The type = number declaration prevents a caller from supplying a nonnumeric value where Terraform cannot convert it. The validation block covers the remaining business rule. floor(var.replica_count) == var.replica_count rejects a number such as 1.5, while the first comparison rejects zero and negative values. Keep the error message specific enough that the person changing a *.tfvars file can correct the input without tracing a provider error.

Do not encode a universal rule merely because it works for this example. Some modules intentionally accept zero to disable a component, or permit a fractional numeric value for a CPU setting. Put the boundary in the module that owns the meaning of the value, then test the cases that its callers are actually allowed to use.

Verify the Terraform archive

Download a pinned archive and the matching official checksum manifest before using a new binary. Select the archive for the runner’s operating system and architecture rather than copying the Linux AMD64 name to a different host.

set -eu
version=1.15.9
base="https://releases.hashicorp.com/terraform/${version}"
archive="terraform_${version}_linux_amd64.zip"

curl -fsSLO "$base/$archive"
curl -fsSLO "$base/terraform_${version}_SHA256SUMS"
grep " $archive$" "terraform_${version}_SHA256SUMS" | sha256sum -c -
unzip "$archive"
chmod 700 terraform
./terraform version

The checksum command must report OK before extracting or executing the archive. In the isolated validation run, the Linux AMD64 archive matched the entry in the Terraform 1.15.9 SHA-256 manifest, and terraform version reported Terraform v1.15.9 on linux_amd64. Keep the archive URL, manifest, platform, and version output with the change record. A package-manager build can have a different version or packaging metadata, so capture the executable that will run the test rather than relying on a workstation installation.

Terraform’s test command warns that test runs can create real infrastructure and attempts cleanup afterward. That warning is important for normal modules. The module here has neither a resource nor a provider, and the test command produced only local plan validation. For a module with cloud resources, run tests only in an authorized disposable account or test environment and verify cleanup independently.

Declare approved and rejected cases

Terraform discovers files ending in .tftest.hcl in the module directory or its test directory. Put the approved input and the deliberate invalid inputs in a file named replica_count.tftest.hcl:

run "accepts_a_whole_positive_count" {
  command = plan

  variables {
    replica_count = 2
  }

  assert {
    condition     = output.replica_count == 2
    error_message = "the test fixture should preserve the approved replica count"
  }
}

run "rejects_zero" {
  command = plan

  variables {
    replica_count = 0
  }

  expect_failures = [var.replica_count]
}

run "rejects_a_fraction" {
  command = plan

  variables {
    replica_count = 1.5
  }

  expect_failures = [var.replica_count]
}

Each run executes a plan with the variables in that block. The first run is a positive test: it supplies the approved value and then checks the module output. The two following runs describe failures that the module is supposed to produce. expect_failures = [var.replica_count] is not an instruction to ignore a failure in a deployment. It tells the test framework that this particular validation error proves the fixture is invalid. If the variable stops failing when the rule is accidentally weakened, the test itself fails.

Run the suite from the module directory:

./terraform test -no-color

The local 1.15.9 run reported all three cases as passed:

replica_count.tftest.hcl... in progress
  run "accepts_a_whole_positive_count"... pass
  run "rejects_zero"... pass
  run "rejects_a_fraction"... pass
replica_count.tftest.hcl... tearing down
replica_count.tftest.hcl... pass

Success! 3 passed, 0 failed.

That output can look counterintuitive at first: the zero and fractional inputs passed as tests because each one produced the failure named in expect_failures. This is useful evidence that the contract has both sides. A positive-only test can show that an ordinary input works, but it cannot show that an accidental edit has removed the intended rejection.

Check that an unexpected invalid value stops the run

For a final diagnostic, make a disposable test file that supplies zero without expect_failures:

run "zero_is_not_approved" {
  command = plan

  variables {
    replica_count = 0
  }
}

Run that file in a separate temporary copy of the module. The same isolated run exited status 1 and reported the configured message:

Error: Invalid value for variable

replica_count must be a whole number of at least 1.

This was checked by the validation rule at main.tf:8,3-13.

This failure is the boundary to preserve in CI. Do not add || true around terraform test, and do not list a failure in expect_failures unless the test case is intentionally exercising a rejection. For an ordinary module change, keep the approved tests and the expected rejected fixtures in version control, then let the CI job fail when the suite reports any unexpected result.

A variable test does not validate a provider API, an existing remote object, quota, state-lock behavior, or an environment-specific policy. Those checks need a plan against an authorized target and, where appropriate, a disposable integration test. The local contract still earns its place before that boundary: it rejects values that the module already knows are invalid, records the reason next to the input definition, and proves that a future edit has not silently reopened the invalid case.

Sources