IaC Testing: Terratest, Terraform Native Testing and Contract Testing
A comprehensive testing strategy for Terraform: Unit testing modules with the native terraform test framework (GA in Terraform 1.6+), integration test with Terratest in Go and contract testing for the module interface.
The Problem of IaC Testing
Testing infrastructure is fundamentally different from testing application code. A unit test of a Terraform module may require actual creation of resources AWS, which cost money and time (minutes, not milliseconds). The feedback loop is long, tests are potentially destructive if not handled properly, and dependencies on the cloud environment are inevitable for integration testing.
Despite these challenges, IaC testing is indispensable: a Terraform module untested it's a time bomb. This article presents a pyramid of tests for IaC with three levels: static tests (fast, cheap), tests with terraform tests (medium), and integration tests with Terratest (slow, in-depth).
Test Pyramid for IaC
- Level 1 - Static (seconds): fmt, validate, tflint, tfsec/checkov. Zero AWS costs.
- Level 2 - Unit/Mock (minutes): terraform test with mock_provider. Zero assets created.
- Level 3 - Integration (5-30 minutes): Terratest: real assets, end-to-end verification.
Terraform Test: The Native Framework (GA from 1.6)
Since Terraform 1.6, the framework terraform test it is General Availability.
Test files have extension .tftest.hcl and allow you to test
the module interface (input/output) with two modes: command apply
(create real resources) e command plan (planning only, zero costs).
# modules/s3-static-website/main.tf - Il modulo da testare
variable "bucket_name" {
type = string
description = "Nome univoco del bucket S3"
}
variable "environment" {
type = string
description = "Ambiente: dev, staging, production"
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "environment deve essere dev, staging o production"
}
}
variable "enable_versioning" {
type = bool
default = false
}
resource "aws_s3_bucket" "website" {
bucket = var.bucket_name
tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "website" {
count = var.enable_versioning ? 1 : 0
bucket = aws_s3_bucket.website.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_website_configuration" "website" {
bucket = aws_s3_bucket.website.id
index_document { suffix = "index.html" }
error_document { key = "error.html" }
}
output "bucket_id" {
value = aws_s3_bucket.website.id
}
output "website_endpoint" {
value = aws_s3_bucket_website_configuration.website.website_endpoint
}
# modules/s3-static-website/tests/unit.tftest.hcl
# Test con command = "plan" - zero risorse create, zero costi
variables {
bucket_name = "test-website-bucket-12345"
environment = "dev"
}
# Test 1: configurazione base
run "basic_plan" {
command = plan
assert {
condition = aws_s3_bucket.website.bucket == "test-website-bucket-12345"
error_message = "Il nome del bucket non corrisponde alla variabile bucket_name"
}
assert {
condition = aws_s3_bucket.website.tags["Environment"] == "dev"
error_message = "Il tag Environment non corrisponde alla variabile environment"
}
assert {
condition = length(aws_s3_bucket_versioning.website) == 0
error_message = "Il versioning non dovrebbe essere abilitato per default"
}
}
# Test 2: con versioning abilitato
run "with_versioning" {
command = plan
variables {
enable_versioning = true
}
assert {
condition = length(aws_s3_bucket_versioning.website) == 1
error_message = "Il versioning dovrebbe essere abilitato"
}
}
# Test 3: validazione input - deve fallire con ambiente invalido
run "invalid_environment_fails" {
command = plan
variables {
environment = "qa" # Non valido: deve fallire
}
expect_failures = [var.environment]
}
# Esegui i test
# In locale:
terraform test
# Output atteso:
# Success! All tests passed.
# 3 passed, 0 failed
Mock Providers: Testing Without Cloud Credentials
From Terraform 1.7, i mock providers allow you to simulate the AWS/Azure/GCP provider without real credentials. Computed values (such as ARN, ID) are automatically generated or configured in the mock.
# modules/s3-static-website/tests/mock.tftest.hcl
# Test con mock provider: nessuna credenziale AWS necessaria
mock_provider "aws" {
# Il provider AWS è simulato: nessuna chiamata API reale
mock_resource "aws_s3_bucket" {
defaults = {
id = "test-website-bucket-12345"
arn = "arn:aws:s3:::test-website-bucket-12345"
bucket_domain_name = "test-website-bucket-12345.s3.amazonaws.com"
bucket_regional_domain_name = "test-website-bucket-12345.s3.eu-west-1.amazonaws.com"
region = "eu-west-1"
}
}
mock_resource "aws_s3_bucket_website_configuration" {
defaults = {
website_endpoint = "test-website-bucket-12345.s3-website-eu-west-1.amazonaws.com"
}
}
}
variables {
bucket_name = "test-website-bucket-12345"
environment = "dev"
}
run "mock_apply_succeeds" {
# command = apply con mock provider: crea risorse simulate
command = apply
assert {
condition = output.bucket_id == "test-website-bucket-12345"
error_message = "output.bucket_id deve corrispondere al mock"
}
assert {
condition = can(regex("s3-website", output.website_endpoint))
error_message = "website_endpoint deve contenere 's3-website'"
}
}
Terratest: Integration Test with Go
Terratest (github.com/gruntwork-io/terratest) is a Go library to write integration tests that create real cloud resources and verify their behavior and destroy them when finished. It is the gold standard for testing complex Terraform modules.
# Struttura per Terratest
modules/
s3-static-website/
main.tf
variables.tf
outputs.tf
tests/
unit.tftest.hcl # terraform test
integration_test.go # Terratest
go.mod
go.sum
// modules/s3-static-website/tests/integration_test.go
package test
import (
"fmt"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/gruntwork-io/terratest/modules/random"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestS3WebsiteModuleBasic crea il modulo, verifica il comportamento, poi distrugge
func TestS3WebsiteModuleBasic(t *testing.T) {
t.Parallel() // I test possono girare in parallelo
// ID univoco per evitare conflitti di naming tra test run
uniqueId := random.UniqueId()
bucketName := fmt.Sprintf("terratest-website-%s", uniqueId)
terraformOptions := &terraform.Options{
TerraformDir: "../",
// Variabili passate al modulo
Vars: map[string]interface{}{
"bucket_name": bucketName,
"environment": "dev",
"enable_versioning": false,
},
// Mostra tutti i log Terraform durante i test
// NoColor: true, // Disabilita colori per log CI puliti
}
// defer garantisce la distruzione delle risorse anche se il test fallisce
defer terraform.Destroy(t, terraformOptions)
// Init e Apply
terraform.InitAndApply(t, terraformOptions)
// Leggi gli output
bucketId := terraform.Output(t, terraformOptions, "bucket_id")
websiteEndpoint := terraform.Output(t, terraformOptions, "website_endpoint")
// Assertions sugli output
assert.Equal(t, bucketName, bucketId, "bucket_id deve essere uguale al bucket_name")
assert.Contains(t, websiteEndpoint, "s3-website", "website_endpoint deve essere una URL website S3")
// Verifica diretta con AWS SDK che il bucket esista e sia configurato correttamente
sess, err := session.NewSession(&aws.Config{Region: aws.String("eu-west-1")})
require.NoError(t, err)
s3Client := s3.New(sess)
// Verifica che il bucket esista
_, err = s3Client.HeadBucket(&s3.HeadBucketInput{
Bucket: aws.String(bucketName),
})
assert.NoError(t, err, "Il bucket deve esistere in AWS")
// Verifica che il website hosting sia abilitato
websiteOutput, err := s3Client.GetBucketWebsite(&s3.GetBucketWebsiteInput{
Bucket: aws.String(bucketName),
})
require.NoError(t, err)
assert.Equal(t, "index.html", *websiteOutput.IndexDocument.Suffix)
assert.Equal(t, "error.html", *websiteOutput.ErrorDocument.Key)
}
// TestS3WebsiteModuleVersioning testa il versioning
func TestS3WebsiteModuleVersioning(t *testing.T) {
t.Parallel()
uniqueId := random.UniqueId()
bucketName := fmt.Sprintf("terratest-versioned-%s", uniqueId)
terraformOptions := &terraform.Options{
TerraformDir: "../",
Vars: map[string]interface{}{
"bucket_name": bucketName,
"environment": "dev",
"enable_versioning": true,
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Verifica versioning con AWS SDK
sess, _ := session.NewSession(&aws.Config{Region: aws.String("eu-west-1")})
s3Client := s3.New(sess)
versioningOutput, err := s3Client.GetBucketVersioning(&s3.GetBucketVersioningInput{
Bucket: aws.String(bucketName),
})
require.NoError(t, err)
assert.Equal(t, "Enabled", *versioningOutput.Status)
}
// TestS3WebsiteIdempotent verifica che apply due volte non cambi nulla
func TestS3WebsiteIdempotent(t *testing.T) {
t.Parallel()
uniqueId := random.UniqueId()
bucketName := fmt.Sprintf("terratest-idempotent-%s", uniqueId)
terraformOptions := &terraform.Options{
TerraformDir: "../",
Vars: map[string]interface{}{
"bucket_name": bucketName,
"environment": "dev",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Secondo apply: non deve cambiare nulla (0 adds, 0 changes, 0 destroys)
exitCode := terraform.PlanExitCode(t, terraformOptions)
assert.Equal(t, 0, exitCode, "Secondo plan deve avere exit code 0 (no changes)")
}
Contract Testing: Verification of Module Interfaces
Il contract testing for Terraform modules verifies that the contract of the module (input and output) meets consumer expectations. It is useful in Shared module repositories to ensure changes don't break consumers.
# modules/s3-static-website/tests/contract.tftest.hcl
# Contract test: verifica che l'interfaccia del modulo rispetti il contratto
mock_provider "aws" {}
# Contract: output.bucket_id deve sempre essere una stringa non vuota
run "contract_bucket_id_not_empty" {
command = apply
variables {
bucket_name = "contract-test-bucket"
environment = "dev"
}
assert {
condition = length(output.bucket_id) > 0
error_message = "CONTRATTO VIOLATO: output.bucket_id non deve essere vuoto"
}
}
# Contract: output.website_endpoint deve essere un URL valido
run "contract_website_endpoint_format" {
command = apply
variables {
bucket_name = "contract-test-bucket"
environment = "dev"
}
assert {
condition = can(regex("^[a-z0-9-]+\\.s3-website[-.]", output.website_endpoint))
error_message = "CONTRATTO VIOLATO: website_endpoint deve essere nel formato S3 website URL"
}
}
# Contract: il modulo NON deve mai creare risorse con tag Environment mancante
run "contract_environment_tag_required" {
command = plan
variables {
bucket_name = "contract-test-bucket"
environment = "staging"
}
assert {
condition = aws_s3_bucket.website.tags["Environment"] != null
error_message = "CONTRATTO VIOLATO: il tag Environment deve sempre essere presente"
}
}
Integration with CI: GitHub Actions for Testing
# .github/workflows/module-test.yml
name: Test Terraform Modules
on:
pull_request:
paths:
- 'modules/**'
permissions:
id-token: write
contents: read
jobs:
# Livello 1: Test statici (0 costi, secondi)
static:
name: Static Analysis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: '1.7.5'
- run: find modules -name "*.tf" -exec terraform fmt -check {} \;
- run: |
for dir in modules/*/; do
cd "$dir"
terraform init -backend=false
terraform validate
cd -
done
# Livello 2: terraform test con mock providers (0 costi AWS, minuti)
unit-test:
name: Unit Tests (terraform test)
runs-on: ubuntu-latest
needs: static
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: '1.7.5'
- name: Run terraform test for each module
run: |
for dir in modules/*/; do
if ls "$dir"tests/*.tftest.hcl 2>/dev/null; then
echo "Testing module: $dir"
cd "$dir"
terraform init -backend=false
terraform test -filter=mock.tftest.hcl -filter=unit.tftest.hcl
cd -
fi
done
# Livello 3: Terratest (crea risorse AWS reali, 5-30 minuti)
integration-test:
name: Integration Tests (Terratest)
runs-on: ubuntu-latest
needs: unit-test
# Esegui solo su PR verso main, non per ogni push
if: github.base_ref == 'main'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Configure AWS Credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/TerratestRole
aws-region: eu-west-1
- name: Run Terratest
run: |
cd modules/s3-static-website/tests
go test -v -timeout 30m -run "TestS3Website"
env:
TF_VAR_environment: dev
Optimize Integration Test Times
Strategies to Reduce Testing Costs and Times
- t.Parallel(): Run Terratest tests in parallel to reduce the total time. With 10 tests of 5 minutes each, 5 minutes are enough in parallel instead of 50.
- Use economic regions: us-east-1 is the most AWS Region economical and with the greatest number of services available.
-
Aggressive Cleanup: USA
defer terraform.Destroy()always as the first operation after creating the options, to ensure destruction even in the event of panic. -
Test only modified modules: USA
pathsin GitHub Actions to run tests only for the modules actually changed in the PR. -
Prefer command = plan: For most tests
logic,
command = planin terraform test it is sufficient and it does not create resources.
Comprehensive Test Strategy for Teams
| Level | Instrument | Where it turns | Duration | AWS cost |
|---|---|---|---|---|
| Static | fmt, validate, tflint | Every PR, local | 10-30s | $0 |
| Security | Checkov, Trivy | Every PR | 30-60s | $0 |
| Unit (plan) | terraform test -plan | Every PR | 1-3 min | $0 |
| Unit (mock) | terraform test -mock | Every PR | 1-5 min | $0 |
| Integration | Terratest | PR → main | 10-30 min | $0.10-2.00 |
| Contract | terraform test -mock | Each PR modules | 2-5 min | $0 |
Conclusions and Next Steps
A three-tier IaC testing strategy — static, unit/mock, and integration —
balance feedback speed with verification depth. The new
framework terraform test native makes basic testing accessible
without external dependencies, while Terratest remains the tool of choice for
verify the real behavior of the infrastructure.
Next Articles in the Series
- Article 6: IaC Security — Checkov, Trivy and OPA Policy-as-Code: automatic security scanning and organizational policies in the pre-apply gate.
- Article 7: Terraform Multi-Cloud — AWS + Azure + GCP with Shared Modules: abstraction layer and multiple provider management.







