Article

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:

  1. Deploy the real S3 bucket to AWS.
  2. Query the S3 API to confirm versioning is enabled.
  3. 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.