Сегодня разбираем, как устроены интеграционные и сквозные тесты в terraform, зачем нужна пирамида тестирования инфраструктуры, как гонять тесты параллельно без того, чтобы они затоптали друг друга, и почему статический анализ - это фундамент, на котором всё стоит. Тема нудновата на словах, но именно она отделяет "у меня apply прошёл" от "я уверен, что инфраструктура как код работает целиком".
Зачем вообще уровни тестов, а не один большой
Логика простая: чем выше уровень теста, тем больше он покрывает, но тем он медленнее, дороже и капризнее. Если всё проверять только сквозными прогонами полной инфры - ты разоришься на облаке и будешь ждать каждый прогон по сорок минут. Если проверять только модули по отдельности - пропустишь ошибки на стыках. Поэтому тесты делят на слои.
- Статический анализ - самый нижний и самый дешёвый слой. terraform validate, terraform fmt, плюс линтеры (tflint) и проверки безопасности (tfsec, checkov, trivy). Код даже не разворачивается - просто читается. Секунды на прогон, ноль рублей за облако.
- Модульные (unit) тесты - проверяют один модуль в изоляции. Развернул сеть - проверил, что подсеть создалась с нужным CIDR, удалил. Минуты, копейки.
- Интеграционные тесты - собирают несколько модулей вместе и проверяют, что они дружат на стыках. Сеть плюс база плюс приложение - и тест лезет по сети, проверяя, что connectivity есть.
- Сквозные (e2e) тесты - разворачивают всю инфру так, как она лежит в проде, и дёргают её снаружи как настоящий пользователь. Самый честный, самый медленный, самый дорогой.

Интеграционные тесты: модули в связке
Интеграционный тест существует ровно потому, что отдельные модули могут быть корректными, а их соединение - нет. Типичный пример из жизни Yandex Cloud: модуль сети отдаёт id подсети, модуль ВМ его принимает, а security group забыл открыть порт между ними.
Тест на Terratest (это библиотека на Go, де-факто стандарт) обычно делает четыре вещи: terraform init и apply на корневой конфиг, который подключает несколько модулей, потом достаёт output-ы, потом проверяет реальное поведение (ping, http-запрос, подключение к БД), и в конце terraform destroy в defer, чтобы убрать за собой даже если тест упал.
Корневой конфиг, который тестируется, выглядит примерно так - тут специально две части, сеть и виртуалка, чтобы было что интегрировать:
Код: Выделить всё
terraform {
required_providers {
yandex = {
source = "yandex-cloud/yandex"
}
}
}
provider "yandex" {
zone = "ru-central1-a"
}
resource "yandex_vpc_network" "this" {
name = "test-net-${var.suffix}"
}
resource "yandex_vpc_subnet" "this" {
name = "test-subnet-${var.suffix}"
zone = "ru-central1-a"
network_id = yandex_vpc_network.this.id
v4_cidr_blocks = ["10.10.0.0/24"]
}
resource "yandex_compute_instance" "app" {
name = "test-app-${var.suffix}"
platform_id = "standard-v3"
zone = "ru-central1-a"
resources {
cores = 2
memory = 2
}
boot_disk {
initialize_params {
image_id = var.image_id
}
}
network_interface {
subnet_id = yandex_vpc_subnet.this.id
nat = true
}
metadata = {
user-data = file("${path.module}/cloud-init.yaml")
}
}
output "external_ip" {
value = yandex_compute_instance.app.network_interface.0.nat_ip_address
}
Параллельность и изоляция: главная боль
Тесты хочется гонять параллельно - иначе пайплайн тянется вечность. Но если два теста одновременно создадут yandex_vpc_network с именем "test-net", второй упадёт с конфликтом имён или, хуже, они переплетутся. Поэтому золотое правило: каждый прогон работает в своём изолированном пространстве с уникальными именами.
Как этого добиваются на практике:
- Уникальный суффикс на каждое имя ресурса. В Terratest это random.UniqueId(), который подставляется в переменную suffix. В чистом HCL ту же роль играет random_id или random_pet. Никаких хардкод-имён в тестах.
- Отдельный state на прогон. Своя папка, свой backend-ключ или своя temp-копия конфигурации - чтобы параллельные прогоны не дрались за один state-файл и не ловили блокировки.
- Изоляция по namespace/каталогу. В Yandex Cloud это отдельный folder_id или даже отдельная папка под тесты, чтобы мусор не смешивался с боевыми ресурсами и легко вычищался.
- Тег с меткой прогона, чтобы потом скриптом снести всё, что вдруг не удалилось.
Код: Выделить всё
resource "random_id" "suffix" {
byte_length = 4
}
resource "yandex_vpc_network" "this" {
name = "ci-net-${random_id.suffix.hex}"
description = "ephemeral network for test run"
labels = {
purpose = "ci-test"
run_id = random_id.suffix.hex
}
}
Этапы тестирования для скорости
Сквозной тест часто состоит из дорогих стадий: развернуть сеть, развернуть кластер, развернуть приложение, проверить, снести. Если ты отлаживаешь только проверку приложения, переразворачивать всю инфру на каждой итерации - пытка. Поэтому тест бьют на этапы (stages), и каждый можно пропускать.
Идея такая: первый прогон делает setup полностью, дальше при отладке ты пропускаешь setup и teardown через переменные окружения и гоняешь только нужный кусок против уже поднятой инфры. В Terratest это test_structure с RunTestStage и переменными вида SKIP_setup_network, SKIP_deploy_app, SKIP_teardown. Когда отладил - снимаешь все skip, и тест прогоняется целиком. Экономит часы.
Время и стоимость прогона - почему форма пирамиды именно такая
Грубая прикидка по слоям. Статика - секунды и бесплатно, гоняй на каждый коммит. Unit - минуты, копейки, гоняй на каждый пуш. Интеграционные - десятки минут, заметные деньги за облако, гоняй на pull request. E2e - десятки минут и больше, дорого, гоняй на merge в main или по ночам.
Вывод прямой: дешёвых проверок должно быть много, дорогих - мало. Не пытайся покрыть каждую мелочь сквозным тестом - покрой её статикой или unit-ом, а e2e оставь для критичных пользовательских сценариев "развернули всё - снаружи работает". Кстати, всё ровно так же справедливо для opentofu - это форк terraform, та же модель команд terraform plan apply, те же подходы к тестам.
Типичные грабли
- Не вызвал destroy - и облако копит зомби-ресурсы, которые жрут бюджет. destroy всегда в defer/cleanup, выполняется даже при падении теста.
- Хардкод имён - параллельные прогоны конфликтуют. Только уникальные суффиксы.
- Перевёрнутая пирамида - всё проверяется e2e, пайплайн идёт час и флапает. Спускай проверки на нижние слои.
- Флаки из-за общего state - два теста на одном backend-ключе ловят блокировки. Изолируй terraform state по прогонам.
- Пропустил статику - и узнаёшь о невалидном HCL только после десяти минут apply. validate и линтеры - первым делом, до любого развёртывания.
- Тестируешь руками то, что ловит fmt/validate - не трать дорогие слои на то, что бесплатно ловит нижний.
- Возьми корневой конфиг из урока (сеть плюс ВМ) и добавь random_id-суффикс ко всем именам ресурсов.
- Прогони цепочку руками: terraform fmt -check, потом terraform validate, потом terraform plan, и только потом apply. Прочувствуй, что первые две команды бесплатны и мгновенны.
- После apply забери output external_ip и проверь связность снаружи (ping или curl) - это и есть ручной интеграционный тест.
- Запусти apply во втором каталоге одновременно - убедись, что благодаря суффиксам имена не конфликтуют.
- Обязательно сделай terraform destroy в обоих каталогах. Привыкай убирать за собой.
- Чем интеграционный тест отличается от модульного и какой класс ошибок ловит именно он?
- Почему в пирамиде тестирования e2e-тестов должно быть мало, а статики и unit - много?
- Какими тремя приёмами добиваются изоляции параллельных прогонов и зачем нужен уникальный суффикс в именах?
- Зачем тест разбивают на этапы и что дают переменные SKIP при отладке?
Статический анализ - фундамент: validate, fmt, линтеры ловят ошибки за секунды и даром. Unit проверяют модули поодиночке, интеграционные - их связку и стыки, e2e - всю инфраструктуру как в проде снаружи. Параллелить тесты можно и нужно, но только с изоляцией: уникальные имена, отдельный state, свой folder/namespace. Дроби тесты на этапы, чтобы не переразворачивать всё ради одной проверки. И всегда чисти за собой через destroy - забытые ресурсы в Yandex Cloud стоят реальных денег. Терраформ-тесты - это не бюрократия, а способ спать спокойно после деплоя.Дешёвых проверок - много, дорогих - мало. Это вся пирамида одной фразой.