Terratest Advanced Patterns: Table-Driven Tests, Parallel Testing, and Module Fixtures

Terratest Advanced Patterns: Table-Driven Tests, Parallel Testing, and Module Fixtures

Most Terratest tutorials cover the basics: deploy a module, assert an output, destroy it. That gets you started, but production infrastructure codebases need more. Modules compose, environments differ, and running 40 sequential end-to-end tests takes hours.

This guide covers the advanced Terratest patterns that make infrastructure test suites fast, maintainable, and actually useful in CI.

Prerequisites

You should already have Terratest working with basic terraform.InitAndApply / terraform.Destroy tests. This guide assumes Go familiarity and a working AWS or GCP environment for integration tests.

Table-Driven Tests for Module Variants

The most common advanced pattern is testing a module across multiple configurations. Instead of writing a separate test function per variant, use Go's table-driven test idiom.

package test

import (
    "testing"
    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
)

type S3BucketCase struct {
    name           string
    versioning     bool
    encryption     string
    expectedPublic bool
}

func TestS3BucketVariants(t *testing.T) {
    cases := []S3BucketCase{
        {
            name:           "versioned-aes256",
            versioning:     true,
            encryption:     "AES256",
            expectedPublic: false,
        },
        {
            name:           "no-versioning-kms",
            versioning:     false,
            encryption:     "aws:kms",
            expectedPublic: false,
        },
        {
            name:           "minimal",
            versioning:     false,
            encryption:     "AES256",
            expectedPublic: false,
        },
    }

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

            opts := &terraform.Options{
                TerraformDir: "../modules/s3-bucket",
                Vars: map[string]interface{}{
                    "bucket_name":        "test-" + tc.name,
                    "enable_versioning":  tc.versioning,
                    "encryption_type":    tc.encryption,
                },
            }

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

            bucketName := terraform.Output(t, opts, "bucket_name")
            assert.Contains(t, bucketName, "test-"+tc.name)

            // Assert public access block
            isPublic := checkBucketPublicAccess(t, bucketName)
            assert.Equal(t, tc.expectedPublic, isPublic)
        })
    }
}

The tc := tc capture inside the loop is required in Go for parallel subtests — without it, all goroutines share the final loop value.

Parallel Test Execution

The single biggest speedup in Terratest: run independent tests in parallel. Add t.Parallel() at the start of each test function, and Go's testing framework schedules them concurrently.

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

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

For integration tests that each spin up real cloud resources, parallelize with care:

  1. Unique resource names. Use random.UniqueId() to generate unique names so parallel tests don't collide on resource names.
  2. IAM limits. AWS has service quotas — too many parallel VPCs or EIPs will hit limits.
  3. Test timeouts. Go's default test timeout is 10 minutes. Infrastructure tests need -timeout 60m or more.
import "github.com/gruntwork-io/terratest/modules/random"

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

    uniqueID := random.UniqueId()
    clusterName := fmt.Sprintf("test-cluster-%s", strings.ToLower(uniqueID))

    opts := &terraform.Options{
        TerraformDir: "../modules/ecs-cluster",
        Vars: map[string]interface{}{
            "cluster_name": clusterName,
        },
    }
    defer terraform.Destroy(t, opts)
    terraform.InitAndApply(t, opts)
}

Run with explicit timeout and parallelism:

go test -v -<span class="hljs-built_in">timeout 90m -parallel 8 ./test/...

Reusable Test Fixtures

Production infrastructure modules compose: your ECS cluster needs a VPC, which needs subnets, which needs an internet gateway. Don't repeat this setup in every test.

Create fixture modules in test/fixtures/ that represent "real-world" compositions:

test/
  fixtures/
    vpc-with-subnets/
      main.tf        # creates VPC + subnets using your module
      outputs.tf     # exports vpc_id, subnet_ids
    ecs-with-vpc/
      main.tf        # creates VPC + ECS using both modules
      outputs.tf
  modules/
    test_vpc_test.go
    test_ecs_test.go

Use terraform.OutputList and terraform.OutputMap to extract complex outputs:

func createTestVPC(t *testing.T) (string, []string, *terraform.Options) {
    opts := &terraform.Options{
        TerraformDir: "../fixtures/vpc-with-subnets",
        Vars: map[string]interface{}{
            "vpc_cidr": "10.0.0.0/16",
            "name":     "test-" + random.UniqueId(),
        },
    }
    terraform.InitAndApply(t, opts)
    vpcID := terraform.Output(t, opts, "vpc_id")
    subnetIDs := terraform.OutputList(t, opts, "subnet_ids")
    return vpcID, subnetIDs, opts
}

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

    vpcID, subnetIDs, vpcOpts := createTestVPC(t)
    defer terraform.Destroy(t, vpcOpts)

    ecsOpts := &terraform.Options{
        TerraformDir: "../modules/ecs-cluster",
        Vars: map[string]interface{}{
            "vpc_id":     vpcID,
            "subnet_ids": subnetIDs,
            "name":       "test-ecs-" + random.UniqueId(),
        },
    }
    defer terraform.Destroy(t, ecsOpts)
    terraform.InitAndApply(t, ecsOpts)
}

Staged Test Execution (Init, Plan, Apply, Validate, Destroy)

Terratest supports running individual stages — useful when you want to inspect a plan before applying, or when you want to keep infrastructure up between test runs during development.

Use environment variables to control which stages run:

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

    opts := &terraform.Options{
        TerraformDir: "../modules/s3-bucket",
        Vars: map[string]interface{}{
            "bucket_name": "test-" + random.UniqueId(),
        },
    }

    defer terraform.DestroyE(t, opts) // E suffix = don't fail on error

    // Stage 1: init + plan only
    terraform.Init(t, opts)
    planOutput := terraform.Plan(t, opts)
    assert.Contains(t, planOutput, "aws_s3_bucket")
    assert.NotContains(t, planOutput, "will be destroyed")

    // Stage 2: apply
    terraform.Apply(t, opts)

    // Stage 3: validate
    bucketName := terraform.Output(t, opts, "bucket_name")
    validateBucketExists(t, bucketName)

    // Stage 4: idempotency check — re-apply should produce no changes
    planOutput2 := terraform.Plan(t, opts)
    assert.Contains(t, planOutput2, "No changes")
}

The idempotency check is underused but high-value: it catches modules that apply cleanly but then show drift on every subsequent plan.

Testing Terraform Outputs with JSON

Complex module outputs (lists, maps) come back as JSON strings. Use terraform.OutputJson to decode them:

import "encoding/json"

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

    opts := &terraform.Options{
        TerraformDir: "../modules/network",
        Vars: map[string]interface{}{
            "availability_zones": []string{"us-east-1a", "us-east-1b"},
        },
    }
    defer terraform.Destroy(t, opts)
    terraform.InitAndApply(t, opts)

    // Output type: map(string)
    subnetMapJSON := terraform.OutputJson(t, opts, "subnet_ids_by_az")
    var subnetMap map[string]string
    json.Unmarshal([]byte(subnetMapJSON), &subnetMap)

    assert.Contains(t, subnetMap, "us-east-1a")
    assert.Contains(t, subnetMap, "us-east-1b")
    assert.NotEmpty(t, subnetMap["us-east-1a"])
}

HTTP and Service Validation Helpers

After deploying infrastructure, you often want to validate that services are actually running — not just that Terraform reported success.

import (
    "github.com/gruntwork-io/terratest/modules/http-helper"
    "github.com/gruntwork-io/terratest/modules/retry"
    "time"
)

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

    opts := &terraform.Options{
        TerraformDir: "../modules/alb",
        Vars: map[string]interface{}{
            "name": "test-alb-" + random.UniqueId(),
        },
    }
    defer terraform.Destroy(t, opts)
    terraform.InitAndApply(t, opts)

    albDNS := terraform.Output(t, opts, "alb_dns_name")
    url := fmt.Sprintf("http://%s/health", albDNS)

    // Retry for up to 5 minutes — DNS propagation and health checks take time
    http_helper.HttpGetWithRetry(t, url, nil, 200, "OK", 30, 10*time.Second)
}

HttpGetWithRetry polls the URL, retrying up to maxRetries times with sleepBetweenRetries between each attempt. This accounts for the delay between Terraform completing and the service actually being healthy.

AWS SDK Assertions

Terratest exposes AWS SDK wrappers via the aws package. These let you assert on real cloud state — not just Terraform outputs.

import "github.com/gruntwork-io/terratest/modules/aws"

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

    region := "us-east-1"
    opts := &terraform.Options{
        TerraformDir: "../modules/rds",
        EnvVars: map[string]string{"AWS_DEFAULT_REGION": region},
        Vars: map[string]interface{}{
            "db_name": "testdb" + random.UniqueId(),
        },
    }
    defer terraform.Destroy(t, opts)
    terraform.InitAndApply(t, opts)

    instanceID := terraform.Output(t, opts, "db_instance_id")

    // Assert encryption at rest is enabled
    instance := aws.GetRdsInstanceById(t, instanceID, region)
    assert.True(t, *instance.StorageEncrypted)

    // Assert multi-AZ is enabled (for production modules)
    assert.True(t, *instance.MultiAZ)

    // Assert deletion protection is on
    assert.True(t, *instance.DeletionProtection)
}

Handling Expected Failures

Sometimes you want to test that Terraform rejects invalid configurations. Use terraform.InitAndPlanWithError:

func TestS3BucketRejectsMissingName(t *testing.T) {
    opts := &terraform.Options{
        TerraformDir: "../modules/s3-bucket",
        Vars: map[string]interface{}{
            // Missing required bucket_name
        },
    }

    _, err := terraform.InitAndPlanE(t, opts)
    assert.Error(t, err)
    assert.Contains(t, err.Error(), "bucket_name")
}

CI Pipeline Integration

Run Terratest in CI with parallelism and proper cleanup:

# .github/workflows/terratest.yml
name: Terratest
on:
  pull_request:
    paths:
      - 'modules/**'
      - 'test/**'

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 90
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.21'
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_TEST_ROLE_ARN }}
          aws-region: us-east-1
      - name: Run Terratest
        run: |
          cd test
          go test -v -timeout 90m -parallel 6 ./...
        env:
          TF_VAR_environment: ci

Use a dedicated AWS account or project for CI testing with tightly scoped IAM permissions. Never run integration tests against production.

Test Tagging for Selective Runs

Use Go build tags to separate fast unit-style tests from slow integration tests:

//go:build integration
// +build integration

package test

func TestFullStackDeploy(t *testing.T) {
    // ...
}

Run only integration tests:

go test -tags integration -v -<span class="hljs-built_in">timeout 90m ./...

Run fast tests (no cloud):

go test -v ./...  <span class="hljs-comment"># no -tags, skips integration

Summary

Advanced Terratest comes down to a few key patterns:

Pattern Use When
Table-driven tests Testing one module across multiple configs
t.Parallel() Independent tests that can run concurrently
Test fixtures Shared infra prerequisites across test functions
Staged apply Development iteration, plan inspection
Idempotency check Detecting drift-producing modules
AWS SDK assertions Verifying security posture, not just outputs
Build tags Separating fast unit tests from slow integration tests

The combination of parallel execution and table-driven tests typically reduces suite runtime by 60–80% compared to sequential, single-config tests. Combined with fixture reuse, you get infrastructure test suites that are actually practical to run in CI.

Read more