Scaling Terraform Test in GitLab
As Terraform module counts grow, testing stops being a “nice to have” and becomes the thing that either keeps your platform honest or quietly falls apart. The hard part is rarely writing a single terraform test file — it is making that pattern repeatable across every resource module, every composite module, and every merge request without copying CI logic into a dozen repos or hundreds of repos.
I put together a small public GitLab group, terraform-test, to walk through one way to scale this: resource-specific modules, composite modules that compose them into a solution, and a shared CI template that runs terraform test the same way everywhere.
Two Tiers of Modules
The group is organized into two tiers.
Resource-specific modules own a single Azure resource (or a very small cluster of related resources). Examples:
terraform-azure-resource-groupterraform-azure-network-interfaceterraform-azure-linux-vmterraform-azure-windows-vmterraform-azure-storage-account
These stay small on purpose. A resource module should be easy to reason about, easy to version, and easy to unit test in isolation.
Composite modules take those building blocks and wire them into a solution. The example here is terraform-composite-app-vm, which creates a resource group, a NIC, a Linux or Windows VM, and a storage account:
locals {
effective_network_interface_name = coalesce(var.network_interface_name, "${var.vm_name}-nic-shared")
}
module "resource_group" {
source = "git::https://gitlab.com/terraform-test662822/terraform-azure-resource-group.git"
resource_group_name = var.resource_group_name
location = var.location
tags = var.tags
}
module "network_interface" {
source = "git::https://gitlab.com/terraform-test662822/terraform-azure-network-interface.git"
resource_group_name = module.resource_group.resource_group_name
location = module.resource_group.location
network_interface_name = local.effective_network_interface_name
subnet_id = var.subnet_id
tags = var.tags
}
module "linux_vm" {
count = var.vm_os_type == "linux" ? 1 : 0
source = "git::https://gitlab.com/terraform-test662822/terraform-azure-linux-vm.git"
resource_group_name = module.resource_group.resource_group_name
location = module.resource_group.location
vm_name = var.vm_name
network_interface_name = local.effective_network_interface_name
network_interface_resource_group_name = module.resource_group.resource_group_name
vm_size = var.vm_size
admin_username = var.admin_username
admin_password = var.admin_password
os_disk_size_gb = var.os_disk_size_gb
os_disk_type = var.os_disk_type
source_image = var.linux_source_image
tags = var.tags
depends_on = [module.network_interface]
}
module "windows_vm" {
count = var.vm_os_type == "windows" ? 1 : 0
source = "git::https://gitlab.com/terraform-test662822/terraform-azure-windows-vm.git"
resource_group_name = module.resource_group.resource_group_name
location = module.resource_group.location
vm_name = var.vm_name
subnet_id = var.subnet_id
vm_size = var.vm_size
admin_username = var.admin_username
admin_password = var.admin_password
os_disk_size_gb = var.os_disk_size_gb
os_disk_type = var.os_disk_type
source_image = var.windows_source_image
tags = var.tags
}
module "storage_account" {
source = "git::https://gitlab.com/terraform-test662822/terraform-azure-storage-account.git"
resource_group_name = module.resource_group.resource_group_name
location = module.resource_group.location
storage_account_name = var.storage_account_name
account_tier = var.account_tier
account_replication_type = var.account_replication_type
min_tls_version = var.min_tls_version
create_container = var.create_container
container_name = var.container_name
container_access_type = var.container_access_type
tags = var.tags
}
That split matters for testing. Resource modules answer “does this one resource behave correctly?” Composite modules answer “do these modules compose correctly into the intended solution?”
Small Modules, Small Tests
The resource group module is about as small as it gets:
resource "azurerm_resource_group" "this" {
name = var.resource_group_name
location = var.location
tags = var.tags
}
Because the surface area is tiny, the unit tests can stay focused. Every module in the group follows the same test layout:
tests/unit.tftest.hcl— internal wiring and variable propagationtests/contract.tftest.hcl— public outputs and expected consumer-facing behavior
For the resource group, the unit tests use a mocked Azure provider and command = plan, so no real credentials are required:
# Unit tests — use command=plan with a mock provider so no real Azure credentials
# are required. Tests focus on internal module logic and variable propagation.
mock_provider "azurerm" {}
variables {
resource_group_name = "rg-unit-test"
location = "eastus"
}
run "name_and_location_propagated" {
command = plan
assert {
condition = azurerm_resource_group.this.name == "rg-unit-test"
error_message = "Resource group name must match resource_group_name input."
}
assert {
condition = azurerm_resource_group.this.location == "eastus"
error_message = "Resource group location must match location input."
}
}
run "tags_propagated" {
command = plan
variables {
tags = {
environment = "test"
team = "platform"
}
}
assert {
condition = azurerm_resource_group.this.tags["environment"] == "test"
error_message = "Tag 'environment' must be propagated to the resource group."
}
assert {
condition = azurerm_resource_group.this.tags["team"] == "platform"
error_message = "Tag 'team' must be propagated to the resource group."
}
}
That is the scaling trick at the resource layer: keep modules narrow, keep assertions local, and make the tests cheap enough that every MR can run them.
Testing the Composite
Composite modules need a different test posture. You are no longer asserting against a single resource — you are asserting against wiring, conditionals, and outputs that depend on nested modules.
In terraform-composite-app-vm, the unit tests still mock Azure, but they lean on richer mock_resource / mock_data defaults and use command = apply so computed IDs and outputs resolve. That lets the tests exercise the Linux path, the Windows path, and optional container creation:
run "linux_vm_path" {
command = apply
variables {
vm_os_type = "linux"
network_interface_name = "appvm-nic-shared"
}
assert {
condition = output.selected_vm_os_type == "linux"
error_message = "vm_os_type output must indicate linux path."
}
assert {
condition = output.vm_id != ""
error_message = "Linux VM path must produce a non-empty vm_id output."
}
assert {
condition = output.created_network_interface_id != ""
error_message = "Composite module must create a network interface."
}
}
run "windows_vm_path" {
command = apply
variables {
vm_os_type = "windows"
}
assert {
condition = output.selected_vm_os_type == "windows"
error_message = "vm_os_type output must indicate windows path."
}
assert {
condition = output.vm_id != ""
error_message = "Windows VM path must produce a non-empty vm_id output."
}
assert {
condition = output.storage_account_id != ""
error_message = "Storage account must be created in all paths."
}
}
run "optional_container_path" {
command = apply
variables {
vm_os_type = "linux"
create_container = true
container_name = "data"
}
assert {
condition = output.storage_container_name == "data"
error_message = "Container name output must match when create_container is true."
}
}
The composite outputs are what consumers actually depend on — selected OS type, VM ID, NIC ID, storage account, optional container — so the contract tests assert those stay stable across both OS paths.
One Pipeline, Every Repo
The part that actually scales is not the individual test files. It is the shared GitLab CI template.
Each module repo keeps a short .gitlab-ci.yml and includes the shared project:
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
include:
- project: "terraform-test662822/gitlab-terraform-test-ci-template"
ref: main
file:
- /templates/terraform-test.yml
- /templates/terraform-plan.yml
variables:
TF_ROOT: "."
TF_TEST_DIR: "tests"
TF_RUN_FMT_CHECK: "true"
TF_RUN_PLAN: "false"
The reusable test template lives in gitlab-terraform-test-ci-template and defines the same stages for every consumer:
stages:
- validate
- test
variables:
TF_ROOT: "."
TF_TEST_DIR: "tests"
TF_RUN_FMT_CHECK: "true"
TF_DOCKER_IMAGE: "hashicorp/terraform:1.9"
default:
image:
name: $TF_DOCKER_IMAGE
entrypoint: [""]
cache:
key: "terraform-$CI_PROJECT_ID-$CI_COMMIT_REF_SLUG"
paths:
- $TF_ROOT/.terraform/providers/
policy: pull-push
.terraform_base:
before_script:
- cd "$TF_ROOT"
- terraform init -backend=false -input=false
fmt:
stage: validate
rules:
- if: '$TF_RUN_FMT_CHECK == "true"'
when: on_success
- when: never
script:
- cd "$TF_ROOT"
- terraform fmt -check -recursive
validate:
extends: .terraform_base
stage: validate
script:
- terraform validate
unit_tests:
extends: .terraform_base
stage: test
needs:
- validate
script:
- terraform test -filter="$TF_TEST_DIR/unit.tftest.hcl"
contract_tests:
extends: .terraform_base
stage: test
needs:
- validate
script:
- terraform test -filter="$TF_TEST_DIR/contract.tftest.hcl"
That gives you a consistent path on every MR: fmt -> validate -> unit_tests / contract_tests.
There is also an opt-in Terraform Cloud plan job in templates/terraform-plan.yml. It is gated behind TF_RUN_PLAN, depends on both test jobs succeeding, and drives a plan through the TFC API. Most module MRs can leave it off and still get fast, credential-light feedback from terraform test.
When a new module repo appears, the cost of plugging into this model is roughly fifteen lines of CI config — as long as it follows the same tests/unit.tftest.hcl and tests/contract.tftest.hcl layout.
Conclusion
Scaling Terraform testing in GitLab worked best for me when I stopped treating every module as a special case:
- Keep resource modules small and test them with cheap
plan-based unit tests behind a mock provider. - Use composite modules to assemble solutions, and test the wiring with
apply-time assertions against mocked nested resources and public outputs. - Put the pipeline in one shared template so every repo runs the same
fmt/validate/terraform testpath.
If you want to poke at the full layout, the group is here: https://gitlab.com/terraform-test662822.