Terratest is a Go library from Gruntwork for writing automated tests against real infrastructure. A typical test runs terraform init and terraform apply on a module, checks that the result behaves correctly (outputs, HTTP responses, SSH access), and always runs terraform destroy at the end. In this tutorial you will write a small Terraform module that runs an Nginx container through the Docker provider, then test it with Terratest on Ubuntu 24.04. Using Docker keeps the example free and fast; the same pattern applies to modules that create cloud resources.
Prerequisites
To follow this tutorial you need:
- A server or workstation running Ubuntu 24.04 LTS, such as a CubePath VPS, with a non-root user that has
sudoprivileges. - Docker Engine installed, with your user in the
dockergroup (docker run --rm hello-worldmust work withoutsudo). - Terraform 1.5 or newer installed from HashiCorp's APT repository. Check it with
terraform version. - Basic familiarity with Terraform modules. Prior Go experience helps but is not required.
Step 1 - Installing Go
Terratest is a Go library and current releases require a recent Go toolchain, newer than the golang-go package in the Ubuntu 24.04 archive. Install the official build from go.dev instead. First, find the current version and your architecture:
GO_VERSION=$(curl -fsSL 'https://go.dev/VERSION?m=text' | head -n 1)
ARCH=$(dpkg --print-architecture)
echo "$GO_VERSION $ARCH"
go1.27.1 amd64
Download the archive and replace any previous installation in /usr/local/go:
curl -fsSLO "https://go.dev/dl/${GO_VERSION}.linux-${ARCH}.tar.gz"
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf "${GO_VERSION}.linux-${ARCH}.tar.gz"
rm "${GO_VERSION}.linux-${ARCH}.tar.gz"
Add Go to your PATH for future shells and for the current one:
echo 'export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin' >> ~/.profile
source ~/.profile
Confirm the installation:
go version
go version go1.27.1 linux/amd64
Step 2 - Creating the Terraform module under test
Terratest projects usually keep the modules and the tests side by side. Create this layout:
mkdir -p ~/infra/modules/web-container ~/infra/test
cd ~/infra
Create the module:
nano modules/web-container/main.tf
terraform {
required_version = ">= 1.5"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
}
}
variable "name" {
description = "Name of the container"
type = string
}
variable "external_port" {
description = "Host port bound on 127.0.0.1"
type = number
validation {
condition = var.external_port >= 1024 && var.external_port <= 65535
error_message = "external_port must be between 1024 and 65535."
}
}
variable "message" {
description = "Text served on the index page"
type = string
default = "Hello from Terraform"
}
resource "docker_image" "nginx" {
name = "nginx:1.28"
keep_locally = true
}
resource "docker_container" "web" {
name = var.name
image = docker_image.nginx.image_id
ports {
internal = 80
external = var.external_port
ip = "127.0.0.1"
}
upload {
content = var.message
file = "/usr/share/nginx/html/index.html"
}
}
output "url" {
value = "http://127.0.0.1:${var.external_port}"
}
output "container_name" {
value = docker_container.web.name
}
The module has three inputs, a validation rule that rejects privileged ports, and two outputs. The tests will check both the happy path and the validation rule. The module does not configure the provider itself, so it uses the default local Docker socket.
Step 3 - Initializing the Go test module
Go tests live in their own Go module. Initialize it in the test directory:
cd ~/infra/test
go mod init example.com/infra/test
go: creating new go.mod: module example.com/infra/test
Add Terratest as a dependency:
go get github.com/gruntwork-io/terratest@latest
go: added github.com/gruntwork-io/terratest v1.0.1
The version number may differ. You will run go mod tidy after writing the test to pull in the remaining dependencies, including testify for assertions.
Step 4 - Writing an integration test
Create the test file. Go only treats files ending in _test.go as tests, and test functions must start with Test:
nano web_container_test.go
package test
import (
"fmt"
"strings"
"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"
test_structure "github.com/gruntwork-io/terratest/modules/test-structure"
"github.com/stretchr/testify/assert"
)
func TestWebContainer(t *testing.T) {
t.Parallel()
uniqueID := strings.ToLower(random.UniqueId())
name := fmt.Sprintf("terratest-web-%s", uniqueID)
port := random.Random(20000, 29999)
message := fmt.Sprintf("Hello from %s", uniqueID)
dir := test_structure.CopyTerraformFolderToTemp(t, "..", "modules/web-container")
opts := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: dir,
Vars: map[string]interface{}{
"name": name,
"external_port": port,
"message": message,
},
NoColor: true,
})
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
assert.Equal(t, name, terraform.Output(t, opts, "container_name"))
url := terraform.Output(t, opts, "url")
http_helper.HttpGetWithRetry(t, url, nil, 200, message, 10, 2*time.Second)
}
Here is what each part does:
t.Parallel()lets Go run this test alongside others. To avoid collisions, the container name, port and page content are randomized withrandom.UniqueId()andrandom.Random().CopyTerraformFolderToTempcopies the module to a temporary directory, so parallel tests never share a.terraformdirectory or state file.WithDefaultRetryableErrorsretriesterraformcommands on known transient errors, such as a failed provider download.defer terraform.Destroy(t, opts)is registered before the apply, so cleanup runs even if the apply or an assertion fails halfway through.terraform.Outputreads a module output, andHttpGetWithRetryrequests the URL up to 10 times, 2 seconds apart, until it returns status 200 with exactly the expected body. Retries matter because a container may need a moment to start listening.
Step 5 - Testing input validation
Not every test needs to create resources. A plan is enough to check that the module rejects bad input, which is fast and costs nothing. Append this function to web_container_test.go:
func TestWebContainerRejectsLowPort(t *testing.T) {
t.Parallel()
dir := test_structure.CopyTerraformFolderToTemp(t, "..", "modules/web-container")
opts := &terraform.Options{
TerraformDir: dir,
Vars: map[string]interface{}{
"name": "terratest-invalid",
"external_port": 80,
},
NoColor: true,
}
_, err := terraform.InitAndPlanE(t, opts)
assert.Error(t, err)
assert.Contains(t, err.Error(), "external_port must be between 1024 and 65535")
}
Terratest functions come in pairs. InitAndPlan fails the test on any error, while InitAndPlanE returns the error so you can assert on it. Use the E variants whenever an error is the expected result.
Resolve the dependencies and check that the code compiles:
go mod tidy
go vet ./...
go vet prints nothing when the code is fine.
Step 6 - Running the tests
Run the whole suite from the test directory. Always set an explicit -timeout: Go's default is 10 minutes, and if it expires mid-test the process exits without running the deferred terraform destroy, leaving resources behind:
go test -v -count=1 -timeout 30m ./...
The -count=1 flag disables Go's test cache, which would otherwise skip tests whose code has not changed even though the infrastructure might have. Terratest logs every Terraform command. The end of the output looks like this:
--- PASS: TestWebContainerRejectsLowPort (19.01s)
--- PASS: TestWebContainer (26.71s)
PASS
ok example.com/infra/test 27.481s
To run a single test, filter by name with a regular expression:
go test -v -count=1 -timeout 30m -run 'TestWebContainer$' ./...
Confirm that the test cleaned up after itself. No terratest-web-* containers should remain:
docker ps -a --filter name=terratest-web
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Step 7 - Running the tests in GitHub Actions
Integration tests are most useful when they run on every pull request. GitHub-hosted Ubuntu runners already include Docker, so the workflow only needs Go and Terraform. Create the workflow file in the root of your repository:
mkdir -p ~/infra/.github/workflows
nano ~/infra/.github/workflows/terratest.yml
name: terratest
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-24.04
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: test/go.mod
cache-dependency-path: test/go.sum
- uses: hashicorp/setup-terraform@v3
with:
terraform_wrapper: false
- name: Run Terratest
working-directory: test
run: go test -v -count=1 -timeout 30m ./...
go-version-file installs the Go version declared in go.mod, so local runs and CI always match. The terraform_wrapper: false setting matters: the default wrapper script adds extra text to Terraform's output, which breaks terraform.Output in Terratest.
For modules that create cloud resources, pass the provider credentials as repository secrets through env: on the test step, and use a dedicated test account so a failed cleanup never touches production.
Troubleshooting
go: go.mod requires go >= 1.26: the Go toolchain is too old for the Terratest version. Install a current release as shown in Step 1 rather than the Ubuntugolang-gopackage.panic: test timed out after 10m0s: you rango testwithout-timeout. Resources may have been left behind; remove them manually (here,docker rm -fon the leftover containers) and rerun with-timeout 30m.Bind for 127.0.0.1:NNNNN failed: port is already allocated: two tests picked the same random port or a leftover container still holds it. Rerun the test, and check for leftovers withdocker ps -a.Error acquiring the state lockor odd shared-state errors: two parallel tests are using the same module directory. UseCopyTerraformFolderToTempin every test that runs Terraform.
Conclusion
You installed Go, wrote a Terraform module with input validation, and tested it with Terratest: one test deploys the module and checks its HTTP response, the other verifies that invalid input is rejected at plan time, and both clean up after themselves. The same suite runs in GitHub Actions on every pull request.
From here, you can add tests for the rest of your modules, use Terratest's ssh and docker packages to check servers and images, or split long tests into stages with test_structure.RunTestStage so you can rerun only the validation part while developing.
