Знакомо? С обычным кодом такие фокусы ловят юнит-тесты. Инфраструктура как код - это тоже код, и его точно так же можно и нужно тестировать. Вопрос только в том, что считать тестом. Проверить, что terraform validate не ругается, недостаточно: синтаксис может быть валидным, а инфраструктура - нерабочей. Настоящая проверка - это поднять ресурсы по-настоящему и убедиться, что они действительно делают то, что должны. Именно для этого и придумали Terratest.
Что такое Terratest и почему именно так
Terratest - это библиотека на языке Go от компании Gruntwork. Никакого своего DSL, никаких yaml-описаний тестов. Ты пишешь обычный Go-тест, который внутри себя дёргает terraform apply на реальном коде, ждёт, пока ресурсы поднимутся, делает к ним живые запросы и сверяет результат с ожиданием. После проверки тест сносит за собой всё, что создал.
Ключевая идея, которую важно сразу принять: Terratest проверяет инфраструктуру изнутри, на работающих ресурсах. Это не статический анализ. Это интеграционное тестирование по своей природе, даже когда мы называем его модульным. Ты не угадываешь, появится ли публичный IP у виртуалки - ты реально его получаешь из output и стучишься на него по ssh. Подход дорогой по времени (реальный apply в yandex cloud - это минуты, а не миллисекунды), зато даёт уверенность, которую не даст ничто другое.
Почему Go, а не python или bash? Во-первых, Terratest изначально на Go и тащит с собой готовые хелперы под terraform, http, ssh, ожидания с ретраями. Во-вторых, тесты компилируются, а значит опечатку в имени функции ты поймаешь до запуска, а не на третьей минуте apply. Бонусом: если ты пишешь на opentofu вместо terraform, Terratest умеет и с ним, достаточно указать нужный бинарь.

Анатомия одного теста
Любой тест Terratest строится по одной и той же скелетной схеме, и её стоит выучить как таблицу умножения:
- Описываем опции: где лежит код модуля и какие переменные ему передать.
- Сразу ставим отложенный terraform destroy через defer - чтобы убраться за собой, даже если тест упал.
- Запускаем InitAndApply - реальный init и apply.
- Читаем output модуля.
- Делаем ассерты: http-запрос к балансировщику, ssh на хост, сверка значения output.
Вот как выглядит минимальный осмысленный тест модуля. Тестируемый модуль поднимает виртуалку с nginx, отдаёт её публичный IP в output, а тест проверяет, что http действительно отвечает:
Код: Выделить всё
package test
import (
"fmt"
"testing"
"time"
"github.com/gruntwork-io/terratest/modules/http-helper"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestWebServerModule(t *testing.T) {
terraformOptions := &terraform.Options{
// путь к модулю, который тестируем
TerraformDir: "../examples/web-server",
Vars: map[string]interface{}{
"vm_name": "tt-web-test",
"image_id": "fd80mrhj8fl2oe87o4e1", // ubuntu из Yandex Cloud Marketplace
"subnet_id": "e9bxxxxxxxxxxxxxxxxx",
},
}
// убираемся за собой в любом случае
defer terraform.Destroy(t, terraformOptions)
// реальный init + apply
terraform.InitAndApply(t, terraformOptions)
// читаем output модуля
publicIP := terraform.Output(t, terraformOptions, "external_ip")
url := fmt.Sprintf("http://%s:80", publicIP)
// http-запрос с ретраями: ждём, пока nginx прогреется
expected := "Welcome to nginx"
httphelper.HttpGetWithRetry(t, url, nil, 200, expected, 30, 10*time.Second)
// заодно проверим, что output не пустой
assert.NotEmpty(t, publicIP)
}
Внедрение зависимостей и тестовые этапы
Когда тестов становится много, всплывает боль: каждый из них делает свой полный apply и destroy, и прогон растягивается на десятки минут. Terratest предлагает два приёма, чтобы это лечить.
Первый - внедрение зависимостей. Не зашивай в тест конкретные id ресурсов и регион намертво. Передавай их через переменные окружения или через Vars, а уникальные имена генерируй на лету хелпером random.UniqueId(). Это позволяет одному и тому же тесту крутиться в разных окружениях и не конфликтовать с параллельными прогонами - имена вроде tt-web-a1b2c3 не пересекутся.
Второй приём - тестовые этапы, stages. Terratest умеет разбивать тест на именованные стадии (deploy, validate, teardown) и пропускать те, что ты явно отключил через переменные окружения. Смысл такой: пока отлаживаешь ассерты, ты один раз делаешь deploy, а потом гоняешь только validate сколько угодно раз, не пересоздавая инфраструктуру. Деньги и время экономятся колоссально.
Код: Выделить всё
func TestWithStages(t *testing.T) {
opts := &terraform.Options{TerraformDir: "../examples/web-server"}
// teardown пропустится, если выставить SKIP_teardown=true
defer test_structure.RunTestStage(t, "teardown", func() {
terraform.Destroy(t, opts)
})
test_structure.RunTestStage(t, "deploy", func() {
terraform.InitAndApply(t, opts)
test_structure.SaveTerraformOptions(t, opts.TerraformDir, opts)
})
test_structure.RunTestStage(t, "validate", func() {
opts := test_structure.LoadTerraformOptions(t, "../examples/web-server")
ip := terraform.Output(t, opts, "external_ip")
assert.NotEmpty(t, ip)
})
}
Здесь терминология слегка путает. В Terratest "модульный тест" - это тест одного модуля terraform, развёрнутого изолированно, а не тест всей системы целиком. То есть ты берёшь самый маленький самодостаточный кусок (модуль web-server, модуль network, модуль database) и проверяешь именно его контракт: какие переменные на входе, какие output на выходе, какое поведение у поднятых ресурсов.
Хорошая практика - держать рядом с модулем папку examples с минимальным рабочим примером его использования, и тестировать именно этот пример. Так ты убиваешь двух зайцев: и тест получаешь, и живую документацию, как модуль подключать. А уже на следующем уровне поверх модульных тестов строят интеграционные, где несколько модулей собираются вместе.
Типичные грабли
- Забыл defer destroy - и в облаке копятся зомби-ресурсы. Первое, что пишешь после Options, - отложенный Destroy.
- Тесты в проде. Никогда не гоняй Terratest на боевом проекте и боевом каталоге. Только отдельный тестовый folder в yandex cloud, желательно с лимитами и алертами на расходы.
- Нет ретраев. Проверяешь http или ssh сразу после apply - получаешь флаки-тесты, которые падают через раз. Всегда используй версии с ретраями.
- Хардкод имён. Два параллельных прогона с одинаковым vm_name подерутся за имя. Спасает random.UniqueId().
- Состояние от прошлого упавшего теста. Если destroy не доехал, остатки terraform state и ресурсы могут мешать. Заведи регулярную чистку тестового окружения.
Повторить руками стоит вот что. Возьми любой свой простой модуль на yandex cloud - пусть это будет одна виртуалка с nginx и публичным IP в output. Положи рядом папку examples с вызовом этого модуля. Установи Go и подтяни terratest через go mod. Напиши тест по скелету из урока: Options с путём к примеру, defer Destroy, InitAndApply, чтение output external_ip, HttpGetWithRetry на 80 порт. Запусти go test -v -timeout 30m. Посмотри, как тест реально поднимает виртуалку, ждёт nginx, проверяет ответ и сносит всё за собой. Потом специально сломай модуль (например, не открой 80 порт в security group) и убедись, что тест честно падает.
Контрольные вопросы
- Почему destroy в тесте ставят через defer, а не просто в конце функции?
- Зачем нужны http и ssh функции с ретраями вместо одиночного запроса?
- Что в Terratest называют модульным тестом и чем он отличается от интеграционного?
- Какую проблему решают тестовые этапы (stages) и почему они экономят время?
Terratest тестирует инфраструктуру как код по-взрослому: реально применяет terraform на тестовом окружении и проверяет живые ресурсы, а не строки в конфиге. Скелет любого теста - Options, defer destroy, InitAndApply, чтение output, ассерты с ретраями. Модульный тест - это проверка одного модуля через его пример. Внедрение зависимостей и stages превращают медленные тесты в управляемый процесс. Работает это и с terraform, и с opentofu. Один раз настроив такой контур, ты перестаёшь узнавать о сломанной инфраструктуре от пользователей.