Automated Testing for Terraform with Terratest in CI/CD
Introduction
Making Terraform code changes without automated tests is a gamble. A small refactor can break a module, create security drift, or slow your release process. Terratest and terraform test give you two complementary ways to bring testing discipline to Infrastructure as Code (IaC).
In this post, you'll learn what Terratest is, how it compares to Terraform's built-in testing, and how to implement a practical, CI-ready testing workflow. We'll also show where Terrateam fits in to optimize GitOps-native plans, applies, and policy checks right in your pull requests.
What is Terratest?
Terratest is an open-source Go library from Gruntwork that helps you write automated tests for infrastructure. It provides helpers to run Terraform, validate outputs, hit real endpoints, and clean up resources - even with retries for flaky cloud calls. You write tests in Go, run them with go test, and exercise your infrastructure as close to reality as you need (from unit-ish to full integration).
Why teams use Terratest:
- Realistic coverage - stand up ephemeral infrastructure, assert behavior, then tear it down.
- Go ecosystem - use Go's testing, assertions, and tooling.
- Flexibility - test Terraform, Packer, Kubernetes/Helm, and more, not just .tf files.
Terratest vs. Terraform test
Terraform has a native testing framework via the terraform test command and *.tftest.hcl files. It validates modules and root modules using declarative runs and assertions tied closely to Terraform plans/applies. Terraform is fast and great for module contracts. By contrast, Terratest runs outside Terraform, in Go, and can perform complete end-to-end checks against deployed infrastructure.
At a glance: Terratest vs. Terraform test
Terratest | Terraform test | |
---|---|---|
Language and style | Imperative Go code using the testing package | Declarative HCL test files (run blocks and asserts) |
Scope | Excels at integration tests - provision, check live behavior (HTTP, ports, IAM), and then destroy | Excellent for module interface and plan-level assertions, can validate during plan/apply |
Tooling and ecosystem | Full Go ecosystem including assert libs and parallel tests, cross-tool coverage including Kubernetes, Helm, and Packer) | Native Terraform CLI, easy for module authors and quick CI lint-like checks |
Recommendation: Use both. Start with terraform test to lock down inputs/outputs and plan-time behaviors. Add Terratest for higher-confidence integration tests that touch real cloud services.
How to write a simple test
In this Terratest tutorial, you'll learn how to write a minimal test for a Terraform module that provisions an S3 bucket (or any simple resource). The test will:
- Initialize and apply the module in a temporary workspace
- Assert an output value
- Destroy resources on completion, whether pass or fail
Project layout
repo/
├─ modules/
│ └─ bucket/
│ ├─ main.tf
│ ├─ variables.tf
│ └─ outputs.tf
└─ test/
└─ bucket_test.go
Go module setup
Initialize Go in the repo root and pull Terratest:
go mod init example.com/iac-repo
go get github.com/gruntwork-io/terratest/modules/terraform
Terratest provides Terraform helpers with sensible retries for transient cloud errors.
bucket_test.go
package test
import (
"path/filepath"
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/require"
)
func TestBucketModule(t *testing.T) {
t.Parallel()
// Path to the Terraform code we want to test
tfDir := filepath.Join("modules", "bucket")
// Configure Terraform with default retryable errors
opts := &terraform.Options{
TerraformDir: tfDir,
Vars: map[string]interface{}{
"bucket_name": "tt-example-%[1]d", // assume variable in module
},
}
// Ensure cleanup even if test fails
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
// Validate an output
bucketId := terraform.Output(t, opts, "bucket_id")
require.NotEmpty(t, bucketId, "expected bucket_id output to be set")
}
What this does:
- terraform.InitAndApply provisions the module
- You assert the bucket_id output exists
- defer Destroy ensures cleanup
Use environment variables or a test fixture to inject unique names to avoid collisions when running tests in parallel.
Run it locally:
AWS_REGION=us-east-1 go test -v ./test -timeout 30m
This is one of many terratest examples you can extend with HTTP checks, IAM policy validations, or Kubernetes probes.
CI/CD integration
You can run Terratest in any CI. Many teams start with GitHub Actions because it's close to their PR workflow and secrets. A simple workflow might:
- Check out your project code from GitHub and set up Go.
- Assume cloud credentials (OIDC recommended).
- Run go test ./test.
- Publish artifacts (logs, JUnit) for visibility.
Why this matters: CI ensures every pull request runs tests automatically, blocking merges when infrastructure behavior regresses.
If you're on a GitOps path, Terrateam takes this a step further by centralizing plans, applies, and policies in pull requests: open a PR, it plans; merge, it applies - at scale and with audit trails. It's GitOps-native and supports Terraform and OpenTofu today.
Example Terratest GitHub Actions workflow
name: terratest
on:
pull_request:
paths:
- "modules/**"
- "test/**"
workflow_dispatch: {}
jobs:
test:
runs-on: ubuntu-latest
permissions:
id-token: write # for OIDC
contents: read
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Configure AWS creds via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_IAM_ROLE_ARN }}
aws-region: us-east-1
- name: Run Terratest
run: go test -v ./test -timeout 30m
- name: Upload test artifacts
uses: actions/upload-artifact@v4
with:
name: terratest-logs
path: |
**/terraform.tfstate
**/*.log
You can also adopt a pre-built action maintained by the community to run Terratest, but rolling your own keeps dependencies minimal and lets you tune caching and IAM.
Terratest code examples
1) Validating a public HTTP endpoint
package test
import (
"net/http"
"testing"
"time"
"github.com/gruntwork-io/terratest/modules/http-helper"
"github.com/gruntwork-io/terratest/modules/terraform"
)
func TestServiceResponds200(t *testing.T) {
t.Parallel()
opts := &terraform.Options{TerraformDir: "../modules/service"}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
url := terraform.Output(t, opts, "service_url")
maxRetries := 15
timeBetween := 10 * time.Second
http_helper.HttpGetWithRetry(
t, url, nil, 200, "OK", maxRetries, timeBetween,
)
}
Why it helps: It proves the module deploys a working endpoint, not just a syntactically valid plan.
2) Asserting Terraform output types and values
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestOutputs(t *testing.T) {
opts := &terraform.Options{TerraformDir: "../modules/network"}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
vpcId := terraform.Output(t, opts, "vpc_id")
publicSubnets := terraform.OutputList(t, opts, "public_subnet_ids")
assert.Regexp(t, "^vpc-", vpcId)
assert.GreaterOrEqual(t, len(publicSubnets), 2)
}
Why it helps: It catches regressions in outputs that downstream modules rely on.
3) Combining Terraform test with Terratest
Use terraform test to assert plan-time constraints, then run Terratest for runtime behavior:
tests/network.tftest.hcl
run "plan_module" {
command = plan
variables {
cidr_block = "10.0.0.0/16"
}
assert {
condition = length(outputs.public_subnet_ids.value) >= 2
error_message = "At least two public subnets are required."
}
}
CI sequence: terraform test (fast, contract checks) → go test (integration). This layered approach strikes a balance between speed and confidence.
CI/CD with Terrateam (GitOps-native)
While GitHub Actions or any CI can execute tests, you still need a reliable way to orchestrate plans and apply them across many workspaces with policy, review gates, and audit trails. Terrateam is purpose-built for that:
- PR-native plans and applies - open a PR to get a terraform plan as a comment or check, then merge to apply.
- GitOps-native architecture - no brittle glue, as Terrateam operates directly in your repos and workflows.
- Scale to thousands of workspaces - built for monorepos or many repos with complex dependencies.
- Enterprise features - policy enforcement, drift detection, audit trails, and simple pricing without hidden resource fees.
In practice, you combine them like this:
- Push branch / open PR
- CI runs tests: terraform test + Terratest
- Terrateam posts the plan in the PR with an impact summary
- Reviewers approve
- Merge to apply via Terrateam, with logs and audit trail attached to the PR
Using a GitHub Action gives you both testing confidence and operational safety within pull requests.
Practical tips and pitfalls
- Control blast radius - in Terratest, target small, well-scoped modules first (network, IAM roles), keep timeouts realistic, and always Destroy.
- Parallelize carefully - use t.Parallel() but avoid naming collisions (random suffixes, isolated states)
- Use OIDC in CI - prefer short-lived credentials over long-lived keys when running tests in GitHub Actions (see Terrateam's CI/CD Terraform guide for examples)
- Cache providers/modules - speed up CI by caching .terraform or using a shared module mirror where possible
- Fail fast, log richly - upload terraform logs and tfstate (sanitized) as CI artifacts to debug flaky tests
Conclusion
Testing Terraform isn't optional at scale. Use terraform test to lock down module contracts and Terratest for realistic, integration-level confidence. Then run both automatically in CI so every pull request proves infrastructure behavior before merge.
Streamline your workflow - plans, applies, policy, drift detection, audit trails - directly in pull requests using Terrateam. This GitOps-native solution for Terraform and OpenTofu scales across monorepos and teams, without relying on fragile pipelines. Sign up for Terrateam and make your Terraform updates safer and easier to test and review.