Implementing Test-Driven Development with Terraform

Test-driven development (TDD) isn’t just for application code anymore; Terraform has a native testing framework that lets you plan/apply infrastructure from tests, make assertions in HCL, and even run post-deployment checks that behave like smoke tests. In this post I’ll show you how to do TDD with Terraform (TTD), when to use plan vs apply inside tests, and how to add lightweight “checks” that verify your stack is actually alive. 

What is TDD

Classic Test-Driven Development is a loop: 

  1. Red – write a failing test that describes the behavior you want. 
  2. Green – implement the minimum to make the test pass. 
  3. Refactor – improve the design with the safety net of tests. 

You can bring the same loop to infrastructure: first write a test that encodes the desired behavior (“web app runs on P1v3, HTTPS only, logs are enabled”), then write Terraform until the test passes, and iterate. 

Hashicorp Terraform Test

Terraform has a native test framework. You write .tftest.hcl files with one or more run blocks. Each run can execute either a plan or an apply of your configuration under test, and you write assertions—also in HCL—against outputs from the current (or previous) run(s). When using it “plan” it validates against the generated plan output in the default settings. When using “apply” it will build the actual resources in the target environment, then Terraform automatically destroys anything it created at the end of the test run.   

Key points: 

  • Tests live in files ending with .tftest.hcl (commonly under a tests/ folder). 
  • Each run has a unique name, an optional command = plan|apply (defaults to apply), and optional variables {}. 
  • Assertions can reference any outputs exposed by the configuration executed in that run, and outputs from earlier runs in the same file.   

There’s also provider and resource mocking for cases where you want fast, isolated, no-cloud tests that behave like unit tests.   

TTD with Terraform: mapping TDD concepts to infra 

When building the testing options this is how I recommend using the Terraform features to represent the standard testing types.

Unit tests ↔️ plan-level tests 

  • Use command = plan, avoid creating resources. 
  • Assert on outputs and computed values that are known at plan time (e.g., naming, SKU selection from variables, tags, policy IDs). 
  • Fast, cheap, safe to run in every pull request. 

Smoke/health checks ↔️ Terraform “checks” 

  • Separate from terraform test. Use check blocks to verify “is the thing alive/usable?” like HTTP 200 from an app or certificate still valid. Checks can reference existing resources and data sources, and with HCP Terraform can run continuously for drift/health.   

Plan vs Apply with tests

I prefer to use the “plan” option with test, as with using the “apply” you will have these considerations:

  • Time & Cost: apply provisions real resources; plans are quick and free. 
  • Parallel PRs: multiple contributors running apply-tests can collide on names, quotas, and budgets. 
  • Cleanup: the test runner destroys after the file finishes, but failed runs, provider outages, or leaked states can still bite. 

Instead I would recommend doing:

  • PRs → run plan tests only (fast, cheap). 
  • Main branch (or nightly) → allow a small, curated set of apply tests for critical paths. Use unique names per run and aggressive timeouts. 
  • Consider mocks for “unit-like” coverage when real providers aren’t necessary.  
    Really good for running tests in isolated Terraform Modules. 

Terraform Checks 

Terraform also supports check blocks: assertions that run after provisioning to verify live behaviour. They’re great for smoke tests: “is the web app answering with HTTP 200?” or “is my certificate still valid?” Unlike tests, checks are part of a normal terraform apply and—when you use HCP Terraform—can run continuously to alert you if they start failing.   

However, I recommend using them in an isolated Terraform set of configuration so it can be run independently from your Terraform Plan/Apply. In this you would use purely Data Resources to obtain the information from Azure and then validate against the data. This gives you:

  • Flexibility to run independently
  • Shorter deployment times.
  • Control over testing code.

Here are a few design rules to keep checks robust and lightweight as well: 

  • Put checks in a separate folder from your main Terraform so you can run them independently and faster. 
  • Treat them as smoke tests, not deep inspections—don’t assert the exact names or internal wiring, assert behavior (HTTP 200, policy compliance = zero non-compliant resources). 
  • Avoid coupling checks to ephemeral details; focus on externally observable health. 

Validating Checks

When Checks fail they do not fail the Terraform run or the pipelines, so you don’t actually know when they don’t work. To combat this you can use a script to interrogate the outputted local state file and investigate the checks outputs. This is a PowerShell script I have used post run to print the results to the Azure DevOps console.

# Load and parse the Terraform state check results
$stateFile = ".\checks\test.tfstate"

if (-Not (Test-Path $stateFile)) {
    Write-Error "❌ State file not found: $stateFile"
    exit 1
}

try {
    $state = Get-Content $stateFile -Raw | ConvertFrom-Json
} catch {
    Write-Error "❌ Failed to parse JSON from state file."
    exit 1
}

$checks = $state.check_results
$checks_failed = $false

Write-Host "`n🔍 Validating Terraform Checks..." -ForegroundColor Cyan

foreach ($check in $checks) {
    $status = $check.status
    $configAddr = $check.config_addr

    if ($status -eq "pass") {
        Write-Host "✔ $configAddr" -ForegroundColor Green
    } else {
        $checks_failed = $true
        Write-Host "❌ $configAddr" -ForegroundColor Red
        if ($check.objects -and $check.objects[0].failure_messages) {
            foreach ($msg in $check.objects[0].failure_messages) {
                Write-Host "   - $msg" -ForegroundColor Yellow
            }
        } else {
            Write-Host "   - No failure messages provided." -ForegroundColor DarkYellow
        }
    }
}

if ($checks_failed) {
    Write-Host "`n❌ One or more Terraform checks failed." -ForegroundColor Red
    exit 1
} else {
    Write-Host "`n✅ All Terraform checks passed successfully." -ForegroundColor Green
    exit 0
}

Published by Chris Pateman - PR Coder

A Digital Technical Lead, constantly learning and sharing the knowledge journey.

Leave a message please

This site uses Akismet to reduce spam. Learn how your comment data is processed.