Scaling Terraform Test in GitLab

Posted on May 28, 2026

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-group
  • terraform-azure-network-interface
  • terraform-azure-linux-vm
  • terraform-azure-windows-vm
  • terraform-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 propagation
  • tests/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:

  1. Keep resource modules small and test them with cheap plan-based unit tests behind a mock provider.
  2. Use composite modules to assemble solutions, and test the wiring with apply-time assertions against mocked nested resources and public outputs.
  3. Put the pipeline in one shared template so every repo runs the same fmt / validate / terraform test path.

If you want to poke at the full layout, the group is here: https://gitlab.com/terraform-test662822.