Skip to content
Infrastructure as Code iac testing 4 min read

Terratest

Static analysis and terraform test can tell you that a configuration is well-formed and that its plan matches your expectations, but neither one proves that the infrastructure actually works once it exists. Terratest closes that loop. It is a Go library, maintained by Gruntwork, that runs terraform init, apply, and destroy against a real cloud account, then lets you make assertions over the live resources — HTTP requests, SSH commands, SDK calls — before tearing everything down. It is the standard tool for end-to-end integration testing of Terraform and OpenTofu modules.

How Terratest works

A Terratest test is an ordinary Go test function. Inside it you construct a terraform.Options struct pointing at a module directory, call terraform.InitAndApply to provision the infrastructure, read outputs, assert on them or on the running system, and then call terraform.Destroy from a deferred statement so cleanup always runs — even when an assertion fails.

Because it deploys genuine resources, Terratest is slower and costs real money compared to terraform validate or terraform test’s plan-only mode. The payoff is fidelity: you are testing the actual provider behaviour, IAM permissions, networking, and application boot, not a model of them.

┌─────────────┐   ┌──────────────┐   ┌─────────────┐   ┌──────────┐
│  Init/Apply │ → │ Read outputs │ → │ Assert live │ → │ Destroy  │
│ (real infra)│   │              │   │  resources  │   │ (defer)  │
└─────────────┘   └──────────────┘   └─────────────┘   └──────────┘

When Terratest beats native testing

Concernterraform testTerratest
LanguageHCLGo
SpeedFast (plan) to moderate (apply)Slow — full apply/destroy
Validates real cloud behaviourPartialYes
HTTP / SSH / SDK assertionsNoYes
Multi-stage orchestrationLimitedFull Go control flow
Best forModule logic and contractsEnd-to-end integration

Reach for Terratest when correctness depends on something outside Terraform’s own model: a load balancer that must return 200, an EC2 instance you can SSH into, an S3 bucket policy that must actually deny anonymous reads, or a multi-module deployment that must compose. For pure configuration logic — variable validation, conditional resource counts, output shapes — terraform test is faster and needs no cloud credentials.

Terratest deploys real, billable resources. Always run it against an isolated sandbox account and keep defer terraform.Destroy(...) as the first statement after a successful apply so leaked resources do not accumulate.

A minimal example

Suppose a module under examples/s3 provisions a versioned, private bucket and exposes a bucket_name output:

resource "aws_s3_bucket" "this" {
  bucket = "devcraftly-test-${random_id.suffix.hex}"
}

resource "random_id" "suffix" {
  byte_length = 4
}

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

output "bucket_name" {
  value = aws_s3_bucket.this.bucket
}

The test lives in a test/ directory as a standard Go package. Initialise the module with go mod init and go get github.com/gruntwork-io/terratest, then write:

package test

import (
	"testing"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/gruntwork-io/terratest/modules/aws"
	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/stretchr/testify/assert"
)

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

	awsRegion := "us-east-1"

	terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
		TerraformDir: "../examples/s3",
		Vars:         map[string]interface{}{},
		EnvVars:      map[string]string{"AWS_DEFAULT_REGION": awsRegion},
	})

	// Always clean up, even if an assertion fails.
	defer terraform.Destroy(t, terraformOptions)

	terraform.InitAndApply(t, terraformOptions)

	bucketName := terraform.Output(t, terraformOptions, "bucket_name")
	assert.NotEmpty(t, bucketName)

	// Verify versioning is actually enabled via the AWS API.
	status := aws.GetS3BucketVersioning(t, awsRegion, bucketName)
	assert.Equal(t, "Enabled", status)
}

Run it with the standard Go toolchain. A long timeout is essential because apply and destroy each take time:

cd test
go test -v -timeout 30m

Output:

=== RUN   TestS3Bucket
=== PAUSE TestS3Bucket
=== CONT  TestS3Bucket
TestS3Bucket 2026-06-14T10:02:11Z command.go:185: Running terraform [init]
TestS3Bucket 2026-06-14T10:02:34Z command.go:185: Running terraform [apply -input=false -auto-approve]
TestS3Bucket 2026-06-14T10:03:09Z command.go:185: Apply complete! Resources: 3 added.
TestS3Bucket 2026-06-14T10:03:10Z command.go:185: Running terraform [output -no-color -json bucket_name]
TestS3Bucket 2026-06-14T10:03:12Z command.go:185: Running terraform [destroy -auto-approve -input=false]
TestS3Bucket 2026-06-14T10:03:41Z command.go:185: Destroy complete! Resources: 3 destroyed.
--- PASS: TestS3Bucket (90.41s)
PASS
ok      github.com/devcraftly/s3/test   90.62s

The terraform.WithDefaultRetryableErrors wrapper transparently retries the transient eventual-consistency errors that plague cloud APIs, which keeps tests from flaking on first-call timing.

Working with OpenTofu

Terratest drives the terraform binary by name, so it works against OpenTofu out of the box once you set the binary. Provide the path through TerraformBinary (or the TERRATEST_TERRAFORM_BINARY environment variable):

terraformOptions := &terraform.Options{
	TerraformDir:    "../examples/s3",
	TerraformBinary: "tofu",
}

Best Practices

  • Run every Terratest suite in a dedicated, disposable AWS/Azure/GCP account so an interrupted run never touches production.
  • Make defer terraform.Destroy(t, opts) the first line after a successful InitAndApply so resources are reclaimed even on panic.
  • Wrap options with terraform.WithDefaultRetryableErrors to absorb transient API and eventual-consistency failures.
  • Mark independent tests with t.Parallel() and randomise resource names (e.g. random_id) to avoid collisions across concurrent runs.
  • Use a generous -timeout (20-40m) on go test; the default 10-minute limit will cut off real applies.
  • Keep Terratest for true integration coverage and let terraform test handle fast unit-level logic — running expensive cloud tests on every commit is wasteful.
  • Run integration suites on a schedule or pre-merge gate rather than on every push, and clean up orphaned resources with a periodic sweeper.
Last updated June 14, 2026
Was this helpful?