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:
- Unique resource names. Use
random.UniqueId()to generate unique names so parallel tests don't collide on resource names. - IAM limits. AWS has service quotas — too many parallel VPCs or EIPs will hit limits.
- Test timeouts. Go's default test timeout is 10 minutes. Infrastructure tests need
-timeout 60mor 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.goUse 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: ciUse 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 integrationSummary
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.