Quick answer: Integration testing in Terraform means actually deploying your configuration to a real (usually temporary) cloud environment, checking that the infrastructure behaves as expected β the instance answers on port 22, the load balancer returns HTTP 200 β and then destroying everything. The two main approaches today are Terraform’s built-in terraform test command (available since Terraform 1.6) and Terratest, a Go library from Gruntwork.
terraform plan tells you what Terraform intends to do. It cannot tell you that the security group you wrote actually allows traffic, or that the IAM policy you attached really grants the permission. Integration tests close that gap. In this guide you will learn how the testing pyramid applies to infrastructure, how to write tests with both terraform test and Terratest, how to keep cloud costs under control, and which mistakes make test suites flaky and expensive.
The infrastructure testing pyramid
Not every check needs a live cloud account. Arrange your tests in layers from cheap to expensive:
| Level | Tool | Deploys real resources? | Typical runtime |
|---|---|---|---|
| Static analysis | terraform validate, TFLint, Checkov |
No | Seconds |
| Unit tests | terraform test with command = plan |
No (plan only) | Seconds to a minute |
| Integration tests | terraform test with command = apply, Terratest |
Yes, then destroyed | 5β20 minutes |
| End-to-end tests | Terratest plus HTTP/SSH checks, smoke tests in staging | Yes | 20+ minutes |
Run the cheap layers on every commit and the expensive layers on pull requests or nightly. Getting the fundamentals right in Terraform Core Concepts and Syntax makes every layer above easier, because well-structured modules with clear outputs are far simpler to test.
What makes a module testable
Integration tests assert on outputs. If your module creates an EC2 instance but never outputs its public IP, the test has nothing to connect to. Before writing a single test, make sure the module under test:
- Accepts every environment-specific value (region, name prefix, CIDR) as an input variable, so the test can inject unique values.
- Exposes outputs for anything you want to verify: IDs, ARNs, DNS names, IP addresses.
- Has no hard-coded names that would collide when two test runs happen at the same time.
Here is the small module we will test throughout this article:
# modules/web/main.tf
variable "name" {
type = string
}
variable "instance_type" {
type = string
default = "t3.micro"
validation {
condition = startswith(var.instance_type, "t3.")
error_message = "Only burstable t3 instance types are allowed in this module."
}
}
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.al2023.id
instance_type = var.instance_type
associate_public_ip_address = true
user_data = <<-EOF
#!/bin/bash
dnf install -y nginx && systemctl enable --now nginx
EOF
tags = { Name = var.name }
}
output "instance_id" { value = aws_instance.web.id }
output "public_ip" { value = aws_instance.web.public_ip }
Hands-on: native tests with terraform test
Since Terraform 1.6, you can write tests in HCL itself β no Go, Ruby or PowerShell required. Test files live in a tests/ directory and end in .tftest.hcl. Each run block executes a plan or apply and evaluates assert conditions.
# tests/web.tftest.hcl
provider "aws" {
region = "ap-south-1"
}
variables {
name = "tkh-test-web"
}
# Unit-style: plan only, no resources created
run "rejects_large_instances" {
command = plan
variables {
instance_type = "m5.24xlarge"
}
expect_failures = [var.instance_type]
}
# Integration: really deploys, asserts, then destroys
run "creates_reachable_instance" {
command = apply
assert {
condition = aws_instance.web.public_ip != ""
error_message = "Instance must receive a public IP"
}
assert {
condition = aws_instance.web.tags["Name"] == "tkh-test-web"
error_message = "Name tag was not applied"
}
}
The first run block relies on the validation block in the module’s variable definition and confirms that Terraform correctly refuses the bad value. The second block applies for real. Run it with:
terraform init
terraform test
# tests/web.tftest.hcl... pass
# run "rejects_large_instances"... pass
# run "creates_reachable_instance"... pass
Terraform automatically destroys everything created by apply runs when the file finishes, even if an assertion fails. Native tests are perfect for verifying attributes that Terraform already knows about. What they cannot do on their own is make an HTTP request to the new server β for that you need a general-purpose language.
Hands-on: behavioural tests with Terratest
Terratest is a Go library that wraps the Terraform CLI and adds helpers for HTTP, SSH, AWS, Azure, GCP and Kubernetes. The pattern is always the same: InitAndApply, read outputs, assert against the real world, defer Destroy.
// test/web_test.go
package test
import (
"fmt"
"testing"
"time"
http_helper "github.com/gruntwork-io/terratest/modules/http-helper"
"github.com/gruntwork-io/terratest/modules/random"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestWebModule(t *testing.T) {
t.Parallel()
uniqueName := fmt.Sprintf("tkh-test-%s", random.UniqueId())
opts := &terraform.Options{
TerraformDir: "../modules/web",
Vars: map[string]interface{}{
"name": uniqueName,
},
EnvVars: map[string]string{"AWS_DEFAULT_REGION": "ap-south-1"},
}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
publicIP := terraform.Output(t, opts, "public_ip")
assert.NotEmpty(t, publicIP)
// nginx needs a minute to install; retry up to 30 times, 10s apart
url := fmt.Sprintf("http://%s", publicIP)
http_helper.HttpGetWithRetry(t, url, nil, 200, "", 30, 10*time.Second)
}
Run it from the test/ directory with go mod init once, then:
go mod tidy
go test -v -timeout 30m ./...
Notice the 30-minute timeout: Go’s default of 10 minutes is often too short for cloud provisioning. The defer statement guarantees cleanup even when an assertion panics, and random.UniqueId() lets several developers run the suite simultaneously without name collisions.
Where Kitchen-Terraform and Pester fit today
Older articles recommend Kitchen-Terraform (Ruby, built on Test Kitchen and InSpec) and Pester (PowerShell). Both still work, but their communities have shrunk. Kitchen-Terraform makes sense if your organisation already runs Chef and InSpec compliance profiles; Pester makes sense for Windows-heavy teams who want to verify Active Directory or IIS configuration after Terraform provisions the VM. For a new project, start with terraform test and add Terratest when you need behavioural checks.
Seven integration-testing mistakes to avoid
- Testing against production or a shared dev account. Always use a dedicated sandbox account or subscription with strict budget alerts. Tests create and destroy real resources.
- Hard-coded resource names. Two concurrent runs both try to create
tkh-test-bucket; one fails with a conflict. Always inject a random suffix. - No cleanup on failure. Forgetting
defer terraform.Destroymeans orphaned instances billing you for weeks. Add a scheduled cleanup job (for examplecloud-nukeor an AWS Lambda) as a safety net. - Asserting immediately after apply. DNS propagation, instance boot and package installation take time. Use retry helpers with a sensible timeout instead of
sleep. - Testing the cloud provider instead of your code. Asserting that an S3 bucket exists after
aws_s3_bucketis applied only proves AWS works. Test your logic: naming, tagging, policies, connectivity. - Running integration tests on every commit. They are slow and cost money. Run static checks and plan-only tests on commits; run apply-based tests on PRs to
mainor nightly. - Ignoring dependency ordering. If your test reads an output before the resource that produces it is ready, you get flaky failures. The patterns in Understanding Resource Dependencies and Ordering in Terraform apply to test code too.
Frequently asked questions
Do integration tests cost money?
Yes, but usually very little if resources are destroyed promptly. A t3.micro running for ten minutes costs a fraction of a rupee. Set a budget alarm on the test account anyway.
Can I use terraform test without applying anything?
Yes. Set command = plan in a run block. This validates variable validations, conditions and computed values without touching the cloud, which makes it fast enough to run on every commit.
Do I need to learn Go for Terratest?
Basic Go is enough. Most Terratest files follow the same twenty-line template shown above. Knowing how to read a map, call a function and write an if statement covers ninety percent of real test suites.
How do I test modules that need existing infrastructure, such as a VPC?
Either create the prerequisites in a setup run block (native tests support helper modules via module { source = "./tests/setup" }) or in Terratest apply a fixture directory first and pass its outputs as variables to the module under test.
Key takeaways
- Integration tests deploy real infrastructure, verify behaviour and destroy it β
planalone is not enough. - Use
terraform test(1.6+) for attribute assertions in pure HCL; add Terratest when you need HTTP, SSH or SDK checks. - Isolate tests with a sandbox account and random names; always guarantee cleanup.
- Run cheap checks on every commit and expensive apply-based tests on PRs or nightly.
Want to build Terraform modules that ship with automated tests and CI pipelines, the way platform teams do in production? Our DevOps course walks you through Terraform, testing and pipelines with live projects and placement assistance. You can also watch step-by-step demos on our YouTube channel.


