Skip to main content
RunBook Academy

TerraformXXI · Testing, Linting, and Static AnalysisProduction Terraform

The Layered Testing Model

Intermediate⏱ ~12 minbash

What you'll learn

  • Define the three layers of Terraform testing: unit, integration, and contract
  • Match each layer to the tool that runs it: terraform test with mocks, terraform test against a sandbox, provider acceptance tests
  • Apply the right per-layer discipline: trigger, scope, cost, and ownership
  • Choose the appropriate layer for a given change and explain the trade-offs

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

A production Terraform pipeline cannot afford to do the same thing twice. A terraform plan against real cloud is slow and expensive. A terraform test with mocks is fast and cheap, but proves nothing about real provider behaviour. The layered testing model splits the work into three layers, each with a defined trigger, scope, and cost, and runs the cheap layers first.

The three layers

Layer 3: Contract tests
  - Run by the provider maintainers
  - Prove the provider itself behaves correctly
  - You do not run these; you inherit their guarantee

Layer 2: Integration tests
  - Run by your team
  - terraform test against a disposable sandbox
  - Prove YOUR module behaves correctly against the real provider

Layer 1: Unit tests
  - Run by your team on every PR
  - terraform test with mock_provider and mock_data
  - Cheap and fast; prove the configuration logic is correct

The layers are cumulative. A unit test that passes does not prove the module will work in production. An integration test that passes does not prove the provider is correct. The point is to run the cheapest layer that catches the change.

Layer 1: Unit tests

Unit tests run terraform test with mocked providers. The provider plugins are replaced by inline mock_provider and mock_data blocks. The test verifies the configuration logic: variable validation, conditional blocks, output values, the resource graph shape.

# modules/s3_bucket/tests/unit.tftest.hcl
run "validates_bucket_name" {
  command = plan

  assert {
    condition     = aws_s3_bucket.this.bucket == "test-bucket"
    error_message = "bucket name did not propagate"
  }
}
# Severity: READ-ONLY - mocks replace the real provider.
terraform test

Properties:

  • Cost. Sub-second per test. No network. No cloud spend.
  • Trigger. Every pull request. Every commit.
  • Scope. Configuration logic. Inputs, outputs, conditional blocks, dynamic blocks, the resource graph.
  • Owner. The module author.
  • Limitation. The mock is not the real provider. A test that passes against mock_provider "aws" does not prove AWS will accept the configuration.

Unit tests are covered in detail in the terraform test in Depth and Mock Providers for Unit Tests lessons later in this part.

Layer 2: Integration tests

Integration tests run terraform test against a real provider, in a disposable sandbox account. The sandbox exists for the duration of the test and is destroyed afterwards. The test verifies the module’s behaviour against the real provider API.

There are two production patterns:

Pattern A: live provider in a sandbox account. The test runs against a real AWS, GCP, or Azure subscription owned by the test rig. Resources are created and destroyed. The provider credentials are short-lived.

# Severity: SERVICE-IMPACT - creates real resources in a sandbox.
export AWS_ACCESS_KEY_ID=$SANDBOX_AWS_ACCESS_KEY_ID
export AWS_SECRET_ACCESS_KEY=$SANDBOX_AWS_SECRET_ACCESS_KEY
terraform test -test-directory=tests/integration

Pattern B: LocalStack or equivalent. A local cloud emulator runs in CI. The provider is the real provider, but the cloud is fake. Lower fidelity than a real sandbox, no cloud spend, no cleanup bill.

# modules/s3_bucket/tests/integration.tftest.hcl
provider "aws" {
  access_key                  = "test"
  secret_key                  = "test"
  region                      = "us-east-1"
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    s3 = "http://localhost:4566"
  }
}

run "creates_bucket" {
  command = apply

  assert {
    condition     = aws_s3_bucket.this.bucket == "integration-bucket"
    error_message = "bucket did not create"
  }
}

Properties:

  • Cost. Minutes per test. Real cloud spend (in Pattern A) or local CPU (in Pattern B).
  • Trigger. Merge to main, nightly, or release tags. Not on every PR.
  • Scope. The module’s behaviour against the real provider. Resource creation, IAM, networking, attributes the unit test could not fake.
  • Owner. The platform team.
  • Limitation. The sandbox is single-tenant. Concurrent test runs against the same AWS account collide on resource names. The sandbox account needs quotas scaled to the test throughput.

Layer 3: Contract tests

Contract tests are the provider’s own acceptance tests. They prove that the provider plugin behaves correctly against the real cloud. You do not run them; you inherit the guarantee. A passing terraform plan against the real provider implies the provider’s own contract tests are passing for the version listed in the lock file.

Properties:

  • Cost. Out of band. The provider maintainers pay.
  • Trigger. Provider release.
  • Scope. The provider’s resource schema and API behaviour.
  • Owner. The provider author (HashiCorp, the cloud vendor, the community).
  • Limitation. The guarantee is for the version pinned in .terraform.lock.hcl. A provider upgrade invalidates the guarantee until the new version is tested.

You exercise contract tests indirectly by reading the provider changelog and pinning to a version you have verified in your own integration test.

The testing pyramid in practice

In a healthy Terraform pipeline, the layer distribution is roughly:

Contract tests:   1 vendor suite, hundreds of internal modules
Integration:      10-50 of the most-changed modules
Unit:             every module, every commit

A team that runs only integration tests is paying cloud bills for what unit tests would catch. A team that runs only unit tests is shipping modules that pass mocks but fail on the real provider. The right answer is both, plus the implicit guarantee of contract tests.

Choosing the right layer for a change

ChangeLayerWhy
Rename a variableUnitPure refactor; mocks catch the typo
Add a new for_eachUnitConditional logic is the mock’s strength
Change a provider attributeIntegrationReal provider behaviour matters
Bump a provider versionIntegration + manualContract bounds may have shifted
Change a module’s output shapeUnitConsumers will catch the rest
Adopt a new resource typeIntegrationMocks have no schema for the new type

The rule of thumb: if the change is to your configuration, unit test first. If the change is to the provider’s API, integration test first.

Failure modes

1. Unit test passes, production breaks

Symptom: CI green, then the first apply in staging fails on a provider argument that the mock accepted. Cause: the mock provider accepts any argument. The mock_provider block does not validate the schema. Fix: add an integration test for the module; do not trust the unit test alone.

2. Integration test runs in production account

Symptom: a CI log shows Apply complete! Resources: 50 added in the production account. Cause: the integration test credentials were misconfigured. Fix: scope the sandbox credentials to a separate AWS account with a deny-by-default service control policy. Re-run the destroyed resources.

3. Unit test suite is slow

Symptom: terraform test takes more than 30 seconds across the module. Cause: the test is using command = apply and the mock provider runs a real apply engine. Fix: prefer command = plan for unit tests. The apply command is for integration tests against a real provider.

4. Integration tests collide on resource names

Symptom: parallel CI jobs fail with ResourceAlreadyExistsException. Cause: the sandbox account is shared; concurrent creates race. Fix: name resources with a per-run suffix (CI job ID, timestamp). Most teams enforce this in a test helper.

5. Provider upgrade passes unit, fails integration

Symptom: provider bumped from 5.40 to 5.50, unit tests pass, integration fails on a renamed attribute. Cause: the unit test mock accepts the renamed attribute because the mock has no schema. Fix: integration tests are the gate for provider upgrades. Run them on every provider bump.

6. Test confidence is high but rollback is hard

Symptom: the team trusts the tests but cannot roll back a failing change quickly. Cause: tests prove the change works, not that the change can be undone. Fix: keep the state file in a versioned backend. The rollback story is the state file, not the tests.

Security and performance implications

Unit tests are local and free. Integration tests against a real sandbox are not: they consume cloud quota, cost money, and require credentials. The cost is the reason integration tests run on a schedule, not on every PR. The security implication is that integration test credentials are production-grade IAM keys scoped to a sandbox account; they must be rotated, scoped to a single OU, and excluded from break-glass alerting.

Contract tests are inherited. The lesson is to pin provider versions in .terraform.lock.hcl and read the provider changelog before bumping.

Production guidance

  • Unit tests on every PR. No exceptions.
  • Integration tests on merge to main, on nightly, and on provider version bumps.
  • Treat unit tests as a sanity check, not a proof. They catch the cheap errors. They do not catch the provider errors.
  • Put integration tests in a separate AWS account with a deny-everything SCP and a quota override. The production account is never the integration sandbox.
  • Pin the provider version in .terraform.lock.hcl. The lock file is the boundary between the contract tests you inherited and the integration tests you run.

Verification

# Unit tests
terraform test -test-directory=tests/unit

# Integration tests against a sandbox
export AWS_ACCESS_KEY_ID=$SANDBOX_AWS_ACCESS_KEY_ID
export AWS_SECRET_ACCESS_KEY=$SANDBOX_AWS_SECRET_ACCESS_KEY
terraform test -test-directory=tests/integration

Unit tests finish in seconds. Integration tests run on merge and nightly. Contract tests are inherited from the provider release.

Knowledge check · 7 questions

  1. Q1. Which layer of Terraform testing runs against the real provider in a sandbox account?

  2. Q2. Why should unit tests be cheap and run on every PR?

  3. Q3. Your team should run the provider's own acceptance tests as part of every CI pipeline.

  4. Q4. A teammate changes a single argument on an `aws_instance` resource. Which layer should the change go through first?

  5. Q5. Which of the following are properties of integration tests? (Select all that apply.)

  6. Q6. What is the role of `.terraform.lock.hcl` in the layered testing model?

  7. Q7. A module has only unit tests. Production breaks because the provider rejected an argument the mock accepted. What is the next step?

Passing score: 75%. Answers are checked in this browser.