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 sudo privileges.
  • Docker Engine installed, with your user in the docker group (docker run --rm hello-world must work without sudo).
  • 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 with random.UniqueId() and random.Random().
  • CopyTerraformFolderToTemp copies the module to a temporary directory, so parallel tests never share a .terraform directory or state file.
  • WithDefaultRetryableErrors retries terraform commands 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.Output reads a module output, and HttpGetWithRetry requests 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 Ubuntu golang-go package.
  • panic: test timed out after 10m0s: you ran go test without -timeout. Resources may have been left behind; remove them manually (here, docker rm -f on 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 with docker ps -a.
  • Error acquiring the state lock or odd shared-state errors: two parallel tests are using the same module directory. Use CopyTerraformFolderToTemp in 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.