How to Test Terraform Modules: Worked Examples with Terraform Test and Terratest
by Gary Worthington, More Than Monkeys

If you missed the first part of this series, start with Terratest vs Terraform Test: Which One Should You Actually Use?. It covers the concepts, trade-offs, and reasoning behind both tools before we dive into these examples.
In this article, we’ll walk through real examples of what you can test with each tool, how to structure those tests, and how to combine them into a clean, maintainable workflow.
The goal of testing Terraform
Before we dive into examples, it’s worth restating what we’re actually testing.
Terraform isn’t an application. There are no functions to unit test or API endpoints to mock. What we’re validating is intent.
- Does this module create what we expect?
- Are outputs and inputs wired correctly?
- Does the real infrastructure behave the way the plan said it would?
Terraform Test and Terratest answer these questions at different depths.
Terraform Test checks logic and structure.
Terratest checks the deployed reality.
1. Testing with Terraform Test
Terraform Test is for fast, static validation.
It doesn’t create real infrastructure, instead, it evaluates a plan, exposes resource attributes, and lets you make assertions about them.
Example: validating an S3 module
Here’s a simple S3 module:
# main.tf
resource "aws_s3_bucket" "this" {
bucket_prefix = var.bucket_prefix
force_destroy = true
versioning {
enabled = var.enable_versioning
}
}
output "bucket_name" {
value = aws_s3_bucket.this.bucket
}
Now add a test file:
# main.tftest.hcl
run "check_versioning" {
command = plan
assert {
condition = aws_s3_bucket.this.versioning[0].enabled
error_message = "Versioning should be enabled on all S3 buckets"
}
assert {
condition = startswith(output.bucket_name, var.bucket_prefix)
error_message = "Bucket name should start with the provided prefix"
}
}
When you run:
terraform test
Terraform creates a plan, exposes the resources, and checks your assertions.
If any condition fails, you get a descriptive error without ever deploying anything.
This kind of test is ideal for:
- Validating outputs and naming conventions
- Ensuring resource properties are set correctly
- Preventing developers from breaking established module contracts
- Quickly verifying that a module still makes sense after refactoring
It’s fast, cost-free, and perfect for pull-request gates.
2. Adding deeper checks with mock providers
Terraform Test also supports mock providers - handy for verifying module interactions without talking to the real API.
Here’s a minimal example that uses mocks to validate how a module handles outputs:
# mocks/aws.json
{
"aws_s3_bucket": {
"this": {
"bucket": "mock-bucket-name",
"region": "eu-west-2"
}
}
}
# main.tftest.hcl
run "mock_provider" {
command = plan
providers = {
aws = "./mocks/aws.json"
}
assert {
condition = output.bucket_name == "mock-bucket-name"
error_message = "Output should match mocked bucket name"
}
}
This lets you simulate AWS responses without touching real infrastructure, which is ideal for local dev or CI pipelines that don’t have credentials.
3. Moving from validation to verification with Terratest
Where Terraform Test stops at intent, Terratest goes further.
It provisions real infrastructure, runs checks against live systems, and tears it all down again.
Let’s take the same S3 module and validate it with Terratest.
package test
import (
"testing"
"strings"
"github.com/gruntwork-io/terratest/modules/aws"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestS3BucketIntegration(t *testing.T) {
t.Parallel()
opts := &terraform.Options{
TerraformDir: "../modules/s3",
Vars: map[string]interface{}{
"bucket_prefix": "test-terratest-",
"enable_versioning": true,
},
}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
bucketName := terraform.Output(t, opts, "bucket_name")
assert.True(t, strings.HasPrefix(bucketName, "test-terratest-"))
versioning := aws.GetS3BucketVersioning(t, "eu-west-2", bucketName)
assert.Equal(t, "Enabled", versioning)
}
When you run go test, Terratest will:
- Deploy the real S3 bucket to AWS.
- Query the S3 API to confirm versioning is enabled.
- Destroy everything at the end.
It’s slower and costs a few pennies, but the confidence is absolute — you’ve tested the real behaviour, not just the syntax.
4. A multi-step example: testing a VPC with both tools
Let’s make it more realistic.
Suppose you have a VPC module that creates:
- One VPC with DNS hostnames enabled
- Three public subnets
- An Internet Gateway
Terraform Test version
We can quickly validate these expectations with plan-level assertions:
# vpc.tftest.hcl
run "validate_vpc_structure" {
command = plan
assert {
condition = aws_vpc.main.enable_dns_hostnames
error_message = "DNS hostnames must be enabled on all VPCs"
}
assert {
condition = length(aws_subnet.public) == 3
error_message = "Expected three public subnets"
}
assert {
condition = aws_internet_gateway.this != null
error_message = "VPC must include an Internet Gateway"
}
}
This runs in seconds, ensuring the module definition is correct before it ever hits AWS.
Terratest version
To go further, we can validate that the deployed VPC actually behaves correctly:
func TestVpcConnectivity(t *testing.T) {
t.Parallel()
opts := &terraform.Options{
TerraformDir: "../modules/vpc",
}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
vpcId := terraform.Output(t, opts, "vpc_id")
subnets := terraform.OutputList(t, opts, "public_subnet_ids")
assert.Len(t, subnets, 3, "Expected three subnets")
for _, subnet := range subnets {
hasRoute := aws.IsPublicSubnet(t, "eu-west-2", subnet)
assert.True(t, hasRoute, "Subnet should have route to IGW")
}
dnsEnabled := aws.IsDnsHostnamesEnabled(t, "eu-west-2", vpcId)
assert.True(t, dnsEnabled, "DNS hostnames should be enabled")
}This test doesn’t just check that the subnets exist. It ALSOconfirms they’re actually public, correctly routed, and DNS-enabled.
That’s the core difference: Terraform Test ensures correctness of definition, while Terratest ensures correctness of behaviour.
5. Testing outputs and data flow between modules
You can also combine the two tools.
Terraform Test checks contracts, and Terratest validates integration.
For example, suppose your network module exports a vpc_id, and your compute module expects it as input.
- Terraform Test ensures the output exists and is not empty:
assert {
condition = output.vpc_id != ""
error_message = "VPC ID output must not be empty"
}- Terratest can then deploy both modules in sequence and verify EC2 instances can reach the internet, confirming the network wiring works as intended.
func TestComputeCanAccessInternet(t *testing.T) {
opts := &terraform.Options{
TerraformDir: "../stacks/dev",
}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
publicIp := terraform.Output(t, opts, "instance_public_ip")
httpResponse := http_helper.HttpGetWithRetry(t, "http://"+publicIp, nil, 200, 10, 5*time.Second)
assert.Contains(t, httpResponse, "OK")
}Now you’re testing the full path from Terraform code to real-world outcome.
6. Structuring your test suite
A good Terraform repo separates fast tests from slow ones:
modules/
vpc/
main.tf
main.tftest.hcl # Fast, plan-level tests
test/
vpc_integration_test.go # Slower, real-world tests
Then in CI:
- Run terraform test on every commit.
- Run Terratest nightly, or on merges to main.
You’ll get quick feedback without slowing down development.
7. Handling teardown safely in Terratest
Terratest runs terraform.Destroy() automatically in defer blocks, but you can still shoot yourself in the foot if your tests fail early.
Best practice is to:
- Use unique resource prefixes per test run.
- Use temporary workspaces or randomised suffixes.
- Clean up aggressively even if tests fail.
Example:
opts := &terraform.Options{
TerraformDir: "../modules/app",
EnvVars: map[string]string{
"TF_VAR_prefix": fmt.Sprintf("ci-%d-", time.Now().Unix()),
},
}This ensures each test environment is unique and disposable.
8. Running everything in CI
In GitHub Actions, a typical setup looks like this:
name: Terraform Tests
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform test
integration:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- run: go test ./test -v
- run: terraform destroy -auto-approve || true
This pattern is clean, predictable, and scales as your repo grows.
9. What you gain from both layers
Terraform Test and Terratest serve different purposes - one checks intent, the other checks reality.
Terraform Test: fast confidence
Terraform Test runs in seconds and doesn’t create real infrastructure. It’s built for spee, perfect for CI pipelines or pre-merge validation.
Use it to:
- Catch syntax and logic errors early.
- Validate outputs, inputs, and variables.
- Ensure the plan produces the expected resources.
- Keep naming, tagging, and structure consistent.
It gives you developer-level confidence: you know your code is valid, consistent, and predictable.
Terratest: deep assurance
Terratest runs slower but interacts with real infrastructure. It deploys, validates, and tears down actual systems.
Use it to:
- Confirm infrastructure behaves correctly once deployed.
- Test networking, IAM, DNS, and integration across modules.
- Detect subtle provider or configuration issues.
- Prove end-to-end reliability in real conditions.
It gives you operational-level confidence: you know the system truly works.
Speed versus certainty
You trade runtime for confidence:
- Terraform Test is fast and free, making it ideal for every PR.
- Terratest is slower and costs a bit to run; making it better for staging or pre-release checks.
The right mindset
Don’t think of them as alternatives. Think of them as two halves of a complete safety net.
Terraform Test keeps your feedback loop quick.
Terratest ensures your production behaves.
Together, they give you both speed and safety — the holy grail of infrastructure testing.
10. The pragmatic takeaway
Terraform Test gives you speed.
Terratest gives you truth.
Use Terraform Test to stop bad plans reaching production.
Use Terratest to stop bad assumptions surviving production.
Together, they give you the safety net Terraform has always needed.
by Gary Worthington, More Than Monkeys
Gary Worthington is a software engineer, delivery consultant, and fractional CTO who helps teams move fast, learn faster, and scale when it matters.
Through his consultancy, More Than Monkeys, Gary helps startups and scaleups improve how they build software — from tech strategy and agile delivery to product validation and team development.
Visit morethanmonkeys.co.uk to learn how we can help you build better, faster.
Follow Gary on LinkedIn for practical insights into engineering leadership, agile delivery, and team performance.