Когда Terratest
Нативный .tftest.hcl отлично покрывает: «модуль принимает X, в plan видно
Y». Не покрывает: «после apply реально создался бакет с именем Y, ARN
совпадает с ожиданием, HTTPS endpoint отвечает 200». Это, Terratest.
Это Go-библиотека. Тесты, _test.go файлы рядом с модулем. Запускается
через go test. Внутри теста: terraform.Init, terraform.Apply, любые
проверки через AWS SDK (или curl, или kubectl, что нужно), defer terraform.Destroy.
Цена: писать Go, держать Go-окружение в CI, тесты идут минутами.
Минимальный тест
Структура:
examples/
└── s3-public-bucket/
└── main.tf # пример вызова твоего модуля
modules/
└── s3-bucket/
└── main.tf
test/
└── s3_bucket_test.go
go.mod
go.sum
test/s3_bucket_test.go:
package test
import (
"fmt"
"testing"
"github.com/gruntwork-io/terratest/modules/aws"
"github.com/gruntwork-io/terratest/modules/random"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestS3BucketCreated(t *testing.T) {t.Parallel()
uniqueID := random.UniqueId()
bucketName := fmt.Sprintf("test-bucket-%s", uniqueID)awsRegion := "us-east-1"
tfOpts := &terraform.Options{TerraformDir: "../examples/s3-public-bucket",
Vars: map[string]interface{}{"name": bucketName,
"region": awsRegion,
},
NoColor: true,
}
defer terraform.Destroy(t, tfOpts)
terraform.InitAndApply(t, tfOpts)
gotARN := terraform.Output(t, tfOpts, "bucket_arn")
assert.Contains(t, gotARN, bucketName)
aws.AssertS3BucketExists(t, awsRegion, bucketName)
}
Что происходит по строкам:
random.UniqueId(), суффикс, чтобы два параллельных теста не дрались за одно глобально-уникальное имя бакета.defer terraform.Destroyставится до apply. Если apply упадёт destroy всё равно выполнится.terraform.InitAndApply, init + apply -auto-approve.terraform.Output, читает один output модуля.aws.AssertS3BucketExists, реальный AWS-API вызов через SDK.
Запуск:
cd test
go test -timeout 30m -v
Долгий timeout не зря, поднять+снести инфру это минуты.
Table-driven
Стандартный go-паттерн: один тест прогоняет N сценариев из таблицы:
func TestBucketVariations(t *testing.T) {t.Parallel()
tests := []struct {name string
versioning bool
encryption bool
}{ {"plain", false, false}, {"versioned", true, false}, {"encrypted", false, true}, {"all_features", true, true},}
for _, tc := range tests {tc := tc // closure capture
t.Run(tc.name, func(t *testing.T) {t.Parallel()
tfOpts := &terraform.Options{TerraformDir: "../examples/s3-public-bucket",
Vars: map[string]interface{}{ "name": fmt.Sprintf("test-%s-%s", tc.name, random.UniqueId()),"versioning": tc.versioning,
"encryption": tc.encryption,
},
}
defer terraform.Destroy(t, tfOpts)
terraform.InitAndApply(t, tfOpts)
// assertions per-variation
})
}
}
Запуск конкретного варианта: go test -run TestBucketVariations/versioned.
Retry на eventual consistency
AWS-API не всегда мгновенно консистентен. Бакет «создан», но GetBucketAcl
отвечает 404 ещё пару секунд. Terratest даёт retry-обёртки:
retry.DoWithRetry(t, "Wait for ACL", 10, 5*time.Second, func() (string, error) {acl := aws.GetS3BucketACL(t, awsRegion, bucketName)
if acl == "" { return "", fmt.Errorf("ACL not yet readable")}
return acl, nil
})
10 попыток с 5-сек паузой. Без этого первый CI-run на чистом окружении упадёт случайно, flaky test, ад для команды.
Стейджи: чтобы не разворачивать каждый раз
Долгие тесты, это боль. Терратест умеет «зашить» apply, потом N тестов на том же state'е, потом destroy:
func TestStaged(t *testing.T) { tfOpts := &terraform.Options{ TerraformDir: "../examples/big" } defer test_structure.RunTestStage(t, "destroy", func() {terraform.Destroy(t, tfOpts)
})
test_structure.RunTestStage(t, "deploy", func() {terraform.InitAndApply(t, tfOpts)
})
test_structure.RunTestStage(t, "validate", func() {// bunch of asserts
})
}
Запуск: SKIP_destroy=true go test, оставит инфру, прогонит deploy+validate.
В следующий раз SKIP_deploy=true SKIP_destroy=true go test, только валидируем.
Деплой одной командой, развалидация ста, отдельно.
Что есть в terratest кроме aws и terraform
| Пакет | Зачем |
|---|---|
modules/aws | S3, EC2, IAM, RDS, EKS, ..., assertions и helpers |
modules/k8s | Создать kubeconfig, kubectl apply, wait for pod |
modules/http-helper | HTTP-вызовы с retry, проверить что endpoint отвечает |
modules/docker | docker run/exec, для тестов с локальным контейнером |
modules/shell | RunCommand с capture, что не покрыли остальные |
modules/random | UniqueId, Random*, стабильные суффиксы для имён |
modules/retry | DoWithRetry/DoWithRetryE для flaky-ситуаций |
Terratest vs tftest
| Аспект | tftest (.tftest.hcl) | Terratest (Go) |
|---|---|---|
| Язык теста | HCL | Go |
| Окружение | Нужен только terraform | Нужен Go + AWS-creds (или LocalStack) |
| Что проверяет | Атрибуты Terraform-state | State + реальные API + HTTP + любые SDK |
| Скорость | Секунды | Минуты |
| Mock support | Встроен (mock_provider) | Нет, реальные API |
| Кривая обучения | HCL знаешь, пишешь | Go или с нуля, или вспоминать |
| Когда использовать | Unit-тесты модуля | Integration на LocalStack/AWS |
Реалистично, оба. tftest гоняешь на каждом PR (быстрый), Terratest, раз в день или на merge в main.
Подводные камни
-
Terratest требует крылатой инфры для CI. Go + AWS-creds + достаточно quota. LocalStack помогает, но не покрывает всё, некоторые сервисы AWS Pro-only.
-
Defer destroy не гарантирует destroy. Если процесс убили kill -9 или у тест-runner истёк timeout, defer не выполнится. Чистка ресурсов остаётся на cron-job (например aws-nuke по тэгу).
-
t.Parallel()без unique names = коллизия. Два теста с одинаковыми bucket-name дерутся за глобальное пространство. Всегдаrandom.UniqueId()в каждом имени. -
Большие тесты, большие AWS-bill'ы. Один CI-runner поднял EKS-кластер, зафейлился до destroy, EKS живёт сутки = $$. Поставь cloud-quota alert и/или используй LocalStack где возможно.
-
Terratest стабилизировался, но не «standard». Конкуренты: kitchen-terraform (Ruby, устаревает), terraform-compliance (BDD, см. отдельную KB), собственные bash-скрипты. У Terratest самая большая community и лучший AWS-coverage, для интеграционных тестов это де-факто выбор.