Featured image of post Terratest: Automated Integration Testing for Terraform Infrastructure

Terratest: Automated Integration Testing for Terraform Infrastructure

Terratest is a Go library for writing real integration tests against Terraform modules. It deploys actual infrastructure, validates it, then destroys it β€” covering terraform, http_helper, aws, retry, and test_structure packages with complete Go code examples.

terraform validate checks syntax. terraform plan previews changes. Neither tells you whether the infrastructure you deploy actually works. Terratest fills that gap by deploying real infrastructure, running assertions against it, then destroying it β€” all from Go’s standard testing package.

What Terratest Is and Why It Exists

Terraform’s built-in terraform test command (introduced in v1.6) runs tests against mocked or ephemeral configurations. It is useful for unit-testing individual module logic but it is not designed to validate end-to-end behaviour across real AWS, GCP, or Azure resources.

Terratest takes a different position: deploy everything for real, hit the endpoints, query the APIs, verify the outputs, then tear it all down. This means:

  • An S3 bucket test actually creates the bucket, checks versioning is enabled, checks the policy, then deletes the bucket.
  • An EC2 test actually boots the instance, waits for it to respond to HTTP, then terminates it.
  • A VPC test actually creates the network, confirms routing tables exist, then removes everything.

The tradeoff is cost and time β€” real infrastructure takes minutes and money. The payoff is confidence that your module works in the real world, not just in a simulation.

Terratest is maintained by Gruntwork, open-source under Apache 2.0, currently at v0.56.0 (February 2026). It requires Go >= 1.21.1.

Terratest vs terraform test

Aspectterraform testTerratest
LanguageHCLGo
InfrastructureMocked or ephemeralReal cloud resources
ScopeModule unit testsIntegration / end-to-end
HTTP validationNoYes (http_helper)
AWS/GCP API checksNoYes (aws, gcp packages)
Retry logicLimitedFirst-class (retry package)
Parallel testsLimitedNative via t.Parallel()
Stage skippingNoYes (test_structure)

Use terraform test for fast, cheap unit checks on module logic. Use Terratest when you need proof that the deployed system behaves correctly.

Installation and Project Setup

Install Go >= 1.21.1 from go.dev/dl. Then structure your repository:

1
2
3
4
5
6
7
8
my-terraform-module/
β”œβ”€β”€ main.tf
β”œβ”€β”€ variables.tf
β”œβ”€β”€ outputs.tf
└── test/
    β”œβ”€β”€ go.mod
    β”œβ”€β”€ go.sum
    └── module_test.go

Initialize the Go module inside the test/ directory:

1
2
3
4
5
cd test
go mod init github.com/your-org/your-repo
go get github.com/gruntwork-io/terratest@v0.56.0
go get github.com/stretchr/testify@v1.9.0
go mod tidy

Your go.mod will look like:

1
2
3
4
5
6
7
8
module github.com/your-org/your-repo

go 1.21.1

require (
    github.com/gruntwork-io/terratest v0.56.0
    github.com/stretchr/testify v1.9.0
)

Run tests with an extended timeout β€” infrastructure operations take time:

1
go test -v -timeout 30m ./...

Go’s default timeout is 10 minutes. Most infrastructure tests need 15–30 minutes. Always set -timeout explicitly.

Core Pattern: Deploy, Validate, Destroy

Every Terratest test follows the same three-phase structure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func TestMyModule(t *testing.T) {
    t.Parallel()

    terraformOptions := &terraform.Options{
        TerraformDir: "../",
        Vars: map[string]interface{}{
            "region": "us-east-1",
        },
    }

    // Phase 3: destroy β€” registered first so it runs last, even on failure
    defer terraform.Destroy(t, terraformOptions)

    // Phase 1: deploy
    terraform.InitAndApply(t, terraformOptions)

    // Phase 2: validate
    bucketName := terraform.Output(t, terraformOptions, "bucket_name")
    assert.NotEmpty(t, bucketName)
}

The defer terraform.Destroy(...) line is critical. In Go, deferred calls execute when the surrounding function returns β€” whether it returns normally or due to a test failure. Registering cleanup before deployment guarantees that even if InitAndApply or any assertion panics, the destroy still runs.

The terraform Package

The terraform package wraps the Terraform CLI. Every function has two variants: a plain variant that calls t.Fatal() on error, and an E-suffixed variant that returns error for explicit handling.

terraform.Options

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
    // Required: path to the directory containing .tf files
    TerraformDir: "../examples/my-module",

    // -var flags
    Vars: map[string]interface{}{
        "instance_type": "t3.micro",
        "environment":   "test",
    },

    // -var-file flags
    VarFiles: []string{"test.tfvars"},

    // Suppress color codes in output
    NoColor: true,

    // Override the backend configuration
    BackendConfig: map[string]interface{}{
        "bucket": "my-tf-state",
        "key":    "test/terraform.tfstate",
    },
})

WithDefaultRetryableErrors wraps the options with a set of common transient errors that Terratest will automatically retry β€” things like “connection reset by peer” or “Provider produced inconsistent result after apply”.

InitAndApply

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Runs terraform init then terraform apply -auto-approve
// Fails the test immediately if either command exits non-zero
terraform.InitAndApply(t, terraformOptions)

// E variant: returns error instead of failing the test
output, err := terraform.InitAndApplyE(t, terraformOptions)
if err != nil {
    t.Logf("Apply failed: %v\nOutput: %s", err, output)
    t.FailNow()
}

Output

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Single string output
bucketID := terraform.Output(t, terraformOptions, "bucket_id")

// List output (Terraform list type)
subnetIDs := terraform.OutputList(t, terraformOptions, "subnet_ids")

// Map output (Terraform map type)
tags := terraform.OutputMap(t, terraformOptions, "resource_tags")

// All outputs as map[string]interface{}
allOutputs := terraform.OutputAll(t, terraformOptions)

// Structured output deserialized into a Go struct
type BucketInfo struct {
    Name   string `json:"name"`
    Region string `json:"region"`
}
var info BucketInfo
terraform.OutputStruct(t, terraformOptions, "bucket_info", &info)

Plan

1
2
3
4
5
6
// Run plan and return the raw output
planOutput := terraform.InitAndPlan(t, terraformOptions)

// Get the exit code: 0 = no changes, 1 = error, 2 = changes present
exitCode := terraform.PlanExitCode(t, terraformOptions)
assert.Equal(t, 2, exitCode) // assert that changes will be made

Destroy

1
2
// Always used with defer
defer terraform.Destroy(t, terraformOptions)

The http_helper Package

The http_helper package handles HTTP validation with built-in retry logic β€” essential because newly deployed servers take time to become healthy.

HttpGetWithRetry

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import (
    "crypto/tls"
    "time"
    http_helper "github.com/gruntwork-io/terratest/modules/http-helper"
)

instanceURL := terraform.Output(t, terraformOptions, "instance_url")

tlsConfig := &tls.Config{} // empty = use system CAs

http_helper.HttpGetWithRetry(
    t,
    instanceURL,
    tlsConfig,
    200,            // expected HTTP status code
    "Hello, World", // expected substring in the response body
    30,             // max retries
    5*time.Second,  // sleep between retries
)

This polls instanceURL every 5 seconds up to 30 times (2.5 minutes total). If the server returns 200 with “Hello, World” in the body, the test passes. If the retries are exhausted, the test fails with a descriptive message.

HttpGetWithRetryWithCustomValidation

When you need more control over what constitutes a valid response:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
http_helper.HttpGetWithRetryWithCustomValidation(
    t,
    instanceURL,
    tlsConfig,
    30,
    5*time.Second,
    func(statusCode int, body string) bool {
        return statusCode == 200 && strings.Contains(body, "healthy")
    },
)

Skipping TLS verification (self-signed certs)

1
tlsConfig := &tls.Config{InsecureSkipVerify: true}

Use this only in test environments where you control the infrastructure.

The aws Package

The aws package wraps AWS SDK calls into test-friendly functions.

Region selection

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import "github.com/gruntwork-io/terratest/modules/aws"

// Pick a random stable region (excludes regions that are often flaky in tests)
awsRegion := aws.GetRandomStableRegion(t, nil, nil)

// Restrict to specific regions
awsRegion := aws.GetRandomStableRegion(t, []string{"us-east-1", "us-west-2"}, nil)

// Exclude specific regions
awsRegion := aws.GetRandomStableRegion(t, nil, []string{"ap-southeast-1"})

AMI lookups

1
2
3
4
5
6
7
8
9
// Get the most recent AMI matching filters
amiID := aws.GetMostRecentAmiId(t, awsRegion, "amazon", map[string][]string{
    "name":                []string{"amzn2-ami-hvm-*-x86_64-gp2"},
    "virtualization-type": []string{"hvm"},
})

// Convenience functions for common AMIs
amazonLinuxAMI := aws.GetAmazonLinuxAmi(t, awsRegion)
ubuntuAMI      := aws.GetUbuntu2004Ami(t, awsRegion)

EC2 instance type selection

1
2
3
4
// Pick the first available instance type in the given region
instanceType := aws.GetRecommendedInstanceType(t, awsRegion,
    []string{"t3.micro", "t2.micro", "t3.small"},
)

S3 bucket checks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
bucketID := terraform.Output(t, terraformOptions, "bucket_id")

// Assert the bucket exists
aws.AssertS3BucketExists(t, awsRegion, bucketID)

// Check versioning status: returns "Enabled", "Suspended", or ""
versioningStatus := aws.GetS3BucketVersioning(t, awsRegion, bucketID)
assert.Equal(t, "Enabled", versioningStatus)

// Assert a bucket policy is attached (non-empty)
aws.AssertS3BucketPolicyExists(t, awsRegion, bucketID)

// Get the raw policy JSON
policyJSON := aws.GetS3BucketPolicy(t, awsRegion, bucketID)
assert.Contains(t, policyJSON, "aws:SecureTransport")

// Check server access logging configuration
loggingTarget := aws.GetS3BucketLoggingTarget(t, awsRegion, bucketID)
loggingPrefix := aws.GetS3BucketLoggingTargetPrefix(t, awsRegion, bucketID)
assert.Equal(t, bucketID+"-logs", loggingTarget)
assert.Equal(t, "access-logs/", loggingPrefix)

// Read and write objects
aws.PutS3ObjectContents(t, awsRegion, bucketID, "test-key", strings.NewReader("hello"))
contents := aws.GetS3ObjectContents(t, awsRegion, bucketID, "test-key")
assert.Equal(t, "hello", contents)

EC2 queries

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
instanceID := terraform.Output(t, terraformOptions, "instance_id")

publicIP  := aws.GetPublicIpOfEc2Instance(t, instanceID, awsRegion)
privateIP := aws.GetPrivateIpOfEc2Instance(t, instanceID, awsRegion)

// Find instances by tag
instanceIDs := aws.GetEc2InstanceIdsByTag(t, awsRegion, "Name", "my-web-server")

// Get instance tags
tags := aws.GetTagsForEc2Instance(t, awsRegion, instanceID)
assert.Equal(t, "production", tags["Environment"])

Other AWS services

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Lambda invocation
result := aws.InvokeFunction(t, awsRegion, "my-function", map[string]string{"key": "value"})

// SSM Parameter Store
aws.PutParameter(t, awsRegion, "/myapp/db_url", "Database URL", "postgres://localhost/db")
value := aws.GetParameter(t, awsRegion, "/myapp/db_url")

// Secrets Manager
secretARN := aws.CreateSecretStringWithDefaultKey(t, awsRegion, "Test secret", "my-secret", `{"password":"abc123"}`)
defer aws.DeleteSecret(t, awsRegion, secretARN, true)
secretValue := aws.GetSecretValue(t, awsRegion, secretARN)

The retry Package

Terratest’s retry package gives you explicit retry control independent of any specific infrastructure helper.

DoWithRetry

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import (
    "time"
    "github.com/gruntwork-io/terratest/modules/retry"
)

result := retry.DoWithRetry(
    t,
    "wait for database to be ready",  // description for log messages
    20,                                // max retries
    15*time.Second,                    // sleep between retries
    func() (string, error) {
        // Attempt the operation
        err := pingDatabase(dbHost, dbPort)
        if err != nil {
            return "", fmt.Errorf("database not ready: %w", err)
        }
        return "ready", nil
    },
)
t.Logf("Database status: %s", result)

If the action returns any non-nil error, Terratest sleeps and retries. If all retries are exhausted, the test fails.

FatalError: skip retrying

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
result := retry.DoWithRetry(t, "check endpoint", 10, 5*time.Second, func() (string, error) {
    status, body, err := http_helper.HttpGetE(t, url, nil)
    if err != nil {
        return "", err // retryable β€” network error
    }
    if status == 404 {
        // Not-found is a permanent failure β€” no point retrying
        return "", retry.FatalError{Underlying: fmt.Errorf("got 404 for %s", url)}
    }
    if status != 200 {
        return "", fmt.Errorf("unexpected status %d", status) // retryable
    }
    return body, nil
})

DoWithRetryE

When you want to handle the “all retries exhausted” case yourself rather than failing the test:

1
2
3
4
5
6
7
result, err := retry.DoWithRetryE(t, "check health", 5, 10*time.Second, func() (string, error) {
    return checkHealth()
})
if err != nil {
    t.Logf("Health check never passed: %v", err)
    // custom handling instead of immediate test failure
}

DoWithRetryableErrors

Retry only on specific error patterns (regex-matched):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
result := retry.DoWithRetryableErrors(
    t,
    "apply terraform",
    map[string]string{
        ".*connection reset by peer.*":  "Transient network error",
        ".*timeout.*":                   "Timeout, will retry",
    },
    5,
    30*time.Second,
    func() (string, error) {
        return terraform.InitAndApplyE(t, terraformOptions)
    },
)

Errors not matching any pattern are immediately wrapped in FatalError and the test fails without retrying.

Background polling

1
2
3
4
5
6
7
8
9
// Poll continuously in the background while other test steps run
done := retry.DoInBackgroundUntilStopped(t, "monitor health", 10*time.Second, func() {
    status, _, _ := http_helper.HttpGetE(t, healthURL, nil)
    t.Logf("Health check status: %d", status)
})

// ... run other test steps ...

done() // stop the background goroutine

The test_structure Package

Long-running infrastructure tests are painful to iterate on. If your test takes 20 minutes and fails in the validation step, you don’t want to re-deploy from scratch every time. The test_structure package solves this by splitting a test into named stages that can be skipped independently.

RunTestStage with SKIP_ environment variables

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import testStructure "github.com/gruntwork-io/terratest/modules/test-structure"

func TestMyModule(t *testing.T) {
    t.Parallel()

    workingDir := "../examples/my-module"

    // STAGE 1: deploy
    testStructure.RunTestStage(t, "deploy", func() {
        awsRegion := aws.GetRandomStableRegion(t, nil, nil)

        terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
            TerraformDir: workingDir,
            Vars: map[string]interface{}{
                "region": awsRegion,
            },
        })

        // Save state so later stages can load it
        testStructure.SaveTerraformOptions(t, workingDir, terraformOptions)
        testStructure.SaveString(t, workingDir, "awsRegion", awsRegion)

        terraform.InitAndApply(t, terraformOptions)
    })

    // STAGE 2: validate
    testStructure.RunTestStage(t, "validate", func() {
        terraformOptions := testStructure.LoadTerraformOptions(t, workingDir)
        awsRegion        := testStructure.LoadString(t, workingDir, "awsRegion")

        bucketID := terraform.Output(t, terraformOptions, "bucket_id")
        aws.AssertS3BucketExists(t, awsRegion, bucketID)
    })

    // STAGE 3: teardown
    testStructure.RunTestStage(t, "teardown", func() {
        terraformOptions := testStructure.LoadTerraformOptions(t, workingDir)
        terraform.Destroy(t, terraformOptions)
    })
}

To skip the deploy and teardown stages and re-run only validation:

1
SKIP_deploy=true SKIP_teardown=true go test -v -run TestMyModule -timeout 30m

RunTestStage checks for a SKIP_<stageName> environment variable. If it is set to any non-empty value, the stage body is skipped.

Saving and loading test data

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Save and load TerraformOptions (serialized to JSON in workingDir)
testStructure.SaveTerraformOptions(t, workingDir, terraformOptions)
terraformOptions := testStructure.LoadTerraformOptions(t, workingDir)

// Save and load arbitrary strings
testStructure.SaveString(t, workingDir, "awsRegion", "us-east-1")
region := testStructure.LoadString(t, workingDir, "awsRegion")

// Save and load integers
testStructure.SaveInt(t, workingDir, "port", 8080)
port := testStructure.LoadInt(t, workingDir, "port")

// Save and load EC2 key pairs
testStructure.SaveEc2KeyPair(t, workingDir, keyPair)
keyPair := testStructure.LoadEc2KeyPair(t, workingDir)

Data is written as JSON files inside workingDir. The file path format is <workingDir>/.test-data/<name>.json.

CopyTerraformFolderToTemp

When running multiple tests in parallel that share the same Terraform directory, they will conflict over .terraform/ and terraform.tfstate. The solution is to copy the module to a temp directory per test:

1
2
3
4
5
6
7
8
9
rootFolder               := ".."
terraformFolderRelativeToRoot := "examples/my-module"

// Copies the entire repo to a temp dir, returns path to the module inside it
tempTestFolder := testStructure.CopyTerraformFolderToTemp(t, rootFolder, terraformFolderRelativeToRoot)

terraformOptions := &terraform.Options{
    TerraformDir: tempTestFolder,
}

When any SKIP_* variable is set, CopyTerraformFolderToTemp skips the copy and returns the original path β€” preserving cached state between iterative runs.

Parallel Tests

Running tests in parallel dramatically reduces total CI time when you have multiple independent modules to test.

t.Parallel()

1
2
3
4
5
6
7
8
9
func TestModuleA(t *testing.T) {
    t.Parallel() // this test runs concurrently with other parallel tests
    // ...
}

func TestModuleB(t *testing.T) {
    t.Parallel()
    // ...
}

Namespacing to avoid collisions

When tests run in parallel in the same AWS account, resource names must be unique:

1
2
3
4
import "github.com/gruntwork-io/terratest/modules/random"

uniqueID   := random.UniqueId() // 6-character random alphanumeric string
bucketName := fmt.Sprintf("my-test-bucket-%s", strings.ToLower(uniqueID))

random.UniqueId() generates a short random string suitable for resource name suffixes.

Subtests with parallel table-driven tests

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
func TestS3BucketConfigurations(t *testing.T) {
    t.Parallel()

    testCases := []struct {
        name        string
        withPolicy  bool
        withLogging bool
    }{
        {"with-policy-and-logging", true, true},
        {"policy-only", true, false},
        {"logging-only", false, true},
    }

    for _, tc := range testCases {
        tc := tc // capture range variable
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()

            uniqueID   := random.UniqueId()
            awsRegion  := aws.GetRandomStableRegion(t, nil, nil)
            bucketName := fmt.Sprintf("test-%s-%s", tc.name, strings.ToLower(uniqueID))

            terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
                TerraformDir: "../examples/s3-module",
                Vars: map[string]interface{}{
                    "bucket_name": bucketName,
                    "region":      awsRegion,
                    "with_policy": tc.withPolicy,
                    "with_logging": tc.withLogging,
                },
            })

            defer terraform.Destroy(t, terraformOptions)
            terraform.InitAndApply(t, terraformOptions)

            aws.AssertS3BucketExists(t, awsRegion, bucketName)
            if tc.withPolicy {
                aws.AssertS3BucketPolicyExists(t, awsRegion, bucketName)
            }
        })
    }
}

Each subtest runs in parallel, deploying its own isolated bucket.

Practical Example: Testing an S3 Module End-to-End

This is a complete, runnable example testing a Terraform S3 module with versioning, bucket policy, and server access logging.

The Terraform module (examples/s3-module/main.tf)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
terraform {
  required_version = ">= 1.0.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.region
}

variable "region"      { type = string }
variable "bucket_name" { type = string }
variable "environment" { type = string  default = "test" }
variable "with_policy" { type = bool    default = true }

data "aws_caller_identity" "current" {}

resource "aws_s3_bucket" "main" {
  bucket        = var.bucket_name
  force_destroy = true
  tags = {
    Name        = var.bucket_name
    Environment = var.environment
  }
}

resource "aws_s3_bucket_versioning" "main" {
  bucket = aws_s3_bucket.main.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket" "logs" {
  bucket        = "${var.bucket_name}-logs"
  force_destroy = true
}

resource "aws_s3_bucket_logging" "main" {
  bucket        = aws_s3_bucket.main.id
  target_bucket = aws_s3_bucket.logs.id
  target_prefix = "access-logs/"
}

resource "aws_s3_bucket_policy" "main" {
  count  = var.with_policy ? 1 : 0
  bucket = aws_s3_bucket.main.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "DenyNonTLS"
        Effect    = "Deny"
        Principal = "*"
        Action    = "s3:*"
        Resource  = ["${aws_s3_bucket.main.arn}", "${aws_s3_bucket.main.arn}/*"]
        Condition = {
          Bool = { "aws:SecureTransport" = "false" }
        }
      }
    ]
  })
}

output "bucket_id"   { value = aws_s3_bucket.main.id }
output "bucket_arn"  { value = aws_s3_bucket.main.arn }
output "logs_bucket" { value = aws_s3_bucket.logs.id }

The test (test/s3_module_test.go)

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
package test

import (
    "fmt"
    "strings"
    "testing"
    "time"

    "github.com/gruntwork-io/terratest/modules/aws"
    http_helper "github.com/gruntwork-io/terratest/modules/http-helper"
    "github.com/gruntwork-io/terratest/modules/random"
    "github.com/gruntwork-io/terratest/modules/retry"
    "github.com/gruntwork-io/terratest/modules/terraform"
    testStructure "github.com/gruntwork-io/terratest/modules/test-structure"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestS3Module(t *testing.T) {
    t.Parallel()

    // Copy the module to a temp folder to avoid state conflicts with parallel tests
    workingDir := testStructure.CopyTerraformFolderToTemp(t, "..", "examples/s3-module")

    // -----------------------------------------------------------------------
    // STAGE: deploy
    // -----------------------------------------------------------------------
    testStructure.RunTestStage(t, "deploy", func() {
        awsRegion  := aws.GetRandomStableRegion(t, nil, nil)
        uniqueID   := strings.ToLower(random.UniqueId())
        bucketName := fmt.Sprintf("terratest-s3-%s", uniqueID)

        terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
            TerraformDir: workingDir,
            Vars: map[string]interface{}{
                "region":      awsRegion,
                "bucket_name": bucketName,
                "environment": "automated-testing",
                "with_policy": true,
            },
            NoColor: true,
        })

        testStructure.SaveTerraformOptions(t, workingDir, terraformOptions)
        testStructure.SaveString(t, workingDir, "awsRegion", awsRegion)

        terraform.InitAndApply(t, terraformOptions)
    })

    // -----------------------------------------------------------------------
    // STAGE: validate
    // -----------------------------------------------------------------------
    testStructure.RunTestStage(t, "validate", func() {
        terraformOptions := testStructure.LoadTerraformOptions(t, workingDir)
        awsRegion        := testStructure.LoadString(t, workingDir, "awsRegion")

        // Read outputs
        bucketID   := terraform.Output(t, terraformOptions, "bucket_id")
        logsBucket := terraform.Output(t, terraformOptions, "logs_bucket")

        require.NotEmpty(t, bucketID)
        require.NotEmpty(t, logsBucket)

        // Verify the bucket exists
        aws.AssertS3BucketExists(t, awsRegion, bucketID)

        // Verify versioning is enabled
        versioningStatus := aws.GetS3BucketVersioning(t, awsRegion, bucketID)
        assert.Equal(t, "Enabled", versioningStatus)

        // Verify bucket policy is attached
        aws.AssertS3BucketPolicyExists(t, awsRegion, bucketID)

        // Verify the policy content contains the TLS deny statement
        policy := aws.GetS3BucketPolicy(t, awsRegion, bucketID)
        assert.Contains(t, policy, "aws:SecureTransport")

        // Verify server access logging target
        loggingTarget := aws.GetS3BucketLoggingTarget(t, awsRegion, bucketID)
        loggingPrefix := aws.GetS3BucketLoggingTargetPrefix(t, awsRegion, bucketID)
        assert.Equal(t, logsBucket, loggingTarget)
        assert.Equal(t, "access-logs/", loggingPrefix)

        // Verify we can write and read objects (round-trip test)
        testKey     := "test-objects/hello.txt"
        testContent := "hello from terratest"
        aws.PutS3ObjectContents(t, awsRegion, bucketID, testKey, strings.NewReader(testContent))

        // Use retry to handle eventual consistency
        retry.DoWithRetry(t, "read S3 object", 5, 3*time.Second, func() (string, error) {
            contents := aws.GetS3ObjectContents(t, awsRegion, bucketID, testKey)
            if contents != testContent {
                return "", fmt.Errorf("expected %q, got %q", testContent, contents)
            }
            return contents, nil
        })
    })

    // -----------------------------------------------------------------------
    // STAGE: teardown
    // -----------------------------------------------------------------------
    testStructure.RunTestStage(t, "teardown", func() {
        terraformOptions := testStructure.LoadTerraformOptions(t, workingDir)
        terraform.Destroy(t, terraformOptions)
    })
}

Running the test

1
2
3
4
5
6
7
8
# Full run
go test -v -run TestS3Module -timeout 30m

# Re-run only the validate stage (deploy already done)
SKIP_deploy=true SKIP_teardown=true go test -v -run TestS3Module -timeout 10m

# Re-run validate and teardown (skip deploy)
SKIP_deploy=true go test -v -run TestS3Module -timeout 30m

Practical Example: EC2 + HTTP Validation

This example deploys an EC2 instance that serves a web page, then validates the HTTP response.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
func TestEC2WebServer(t *testing.T) {
    t.Parallel()

    awsRegion    := aws.GetRandomStableRegion(t, nil, nil)
    uniqueID     := random.UniqueId()
    instanceName := fmt.Sprintf("terratest-web-%s", strings.ToLower(uniqueID))
    responseText := fmt.Sprintf("Hello from %s", uniqueID)

    // Pick an available instance type for the chosen region
    instanceType := aws.GetRecommendedInstanceType(t, awsRegion,
        []string{"t3.micro", "t2.micro", "t3.small"},
    )

    // Get a recent Amazon Linux 2 AMI
    amiID := aws.GetMostRecentAmiId(t, awsRegion, "amazon", map[string][]string{
        "name":                {"amzn2-ami-hvm-*-x86_64-gp2"},
        "virtualization-type": {"hvm"},
    })

    terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
        TerraformDir: "../examples/ec2-web",
        Vars: map[string]interface{}{
            "aws_region":     awsRegion,
            "ami_id":         amiID,
            "instance_type":  instanceType,
            "instance_name":  instanceName,
            "response_text":  responseText,
        },
        NoColor: true,
    })

    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)

    instanceURL := terraform.Output(t, terraformOptions, "instance_url")

    // Wait up to 5 minutes for the server to come up
    http_helper.HttpGetWithRetry(
        t,
        instanceURL,
        nil,
        200,
        responseText,
        60,
        5*time.Second,
    )
}

Error Handling Conventions

Every Terratest function follows the same convention:

  • terraform.InitAndApply(t, opts) β€” calls t.Fatal() on error, test stops immediately.
  • terraform.InitAndApplyE(t, opts) β€” returns (string, error), you decide what to do.

The plain variants are appropriate for most tests because failing fast and loud is correct behaviour when a deployment breaks. Use the E variants when you need conditional logic or when you expect partial failures and want to continue.

1
2
3
4
5
6
7
8
9
// Expecting a plan to fail (e.g., testing that invalid input is rejected)
_, err := terraform.InitAndPlanE(t, terraformOptions)
require.Error(t, err, "expected plan to fail with invalid configuration")

// Checking whether a resource exists before asserting on it
exists := aws.GetS3BucketVersioning(t, awsRegion, bucketID)
if exists == "" {
    t.Log("Versioning not configured β€” checking if it was intentionally disabled")
}

Summary

Terratest covers the entire testing surface for Terraform modules:

PackageWhat it does
terraformWraps the Terraform CLI: init, apply, plan, destroy, outputs
http_helperHTTP GET with retry and custom validation functions
awsAWS SDK wrappers: S3, EC2, AMI, RDS, Lambda, SSM, Secrets Manager
retryGeneric retry logic with timeout, fatal errors, and retryable error patterns
test_structureStage-based test execution with persistent state between stages
randomUnique ID generation for resource namespacing

The fundamental workflow never changes: defer Destroy, then InitAndApply, then assert. Everything else is composing those building blocks with retry logic, AWS API calls, and HTTP checks to cover the specific behaviour your module promises to deliver.

References