6 min read

Test Terraform Network Modules with Mocks

Write Terraform tests for a reusable AWS VPC module, mock the provider to avoid cloud resources, and catch invalid CIDR plans before deployment.

ByAndré Ribeiro· Founder, Obelinf
Test Terraform Network Modules with Mocks
Test Terraform Network Modules with Mocks · October 3, 2026
On this page

A Terraform plan can be syntactically valid while still describing the wrong subnet layout. A small module test can check expected CIDRs and resource counts before a change reaches a cloud account, without replacing a full integration test against AWS.

This tutorial creates an AWS VPC module with three private subnet slots, then tests its planned values using Terraform’s mocked provider support. The test command runs locally and does not create real VPCs or require AWS credentials.

A network module is checked before a real provider applyModule inputsparent CIDR and subnet slotsMocked planno AWS resources createdAssertionsCIDRs and counts matchMock tests check configuration logic. A separate deployment test checks real cloud behavior.

Create a module directory

Make a directory called network-module with a tests subdirectory. The root module will accept a /16 VPC range and a map of stable subnet numbers. Each number selects a /24 block within the VPC.

Create main.tf with the AWS provider and inputs:

terraform {
  required_version = ">= 1.7.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

variable "region" {
  type    = string
  default = "us-east-1"
}

variable "vpc_cidr" {
  type = string

  validation {
    condition = can(cidrnetmask(var.vpc_cidr)) && try(
      tonumber(split("/", var.vpc_cidr)[1]) == 16,
      false
    )
    error_message = "vpc_cidr must be a valid IPv4 /16 prefix."
  }
}

variable "subnet_numbers" {
  type = map(number)

  validation {
    condition = length(var.subnet_numbers) > 0 &&
      length(distinct(values(var.subnet_numbers))) == length(var.subnet_numbers) &&
      alltrue([for slot in values(var.subnet_numbers) : slot >= 0 && slot < 256])
    error_message = "Subnet numbers must be unique slots from 0 through 255."
  }

  default = {
    zone_a = 0
    zone_b = 1
    zone_c = 2
  }
}

provider "aws" {
  region = var.region
}

The validation makes this module example accept exactly one valid IPv4 /16 block.

Declare the VPC and private subnets

In the same main.tf, add the resources that the module should plan. Keeping the subnet number in the input map makes the address assignment visible to reviewers.

resource "aws_vpc" "network" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true
}

resource "aws_subnet" "private" {
  for_each          = var.subnet_numbers
  vpc_id            = aws_vpc.network.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, each.value)
  availability_zone = "${var.region}${each.key == "zone_a" ? "a" : each.key == "zone_b" ? "b" : "c"}"
}

output "private_cidrs" {
  value = { for zone, subnet in aws_subnet.private : zone => subnet.cidr_block }
}

This module creates no route table or internet path, so it is not a complete production network. The focused example gives the tests a clear contract: a known parent range must yield three unique /24 subnet ranges.

The input validation protects two important invariants. A /16 divided into /24 blocks has 256 theoretical slots, indexed from 0 through 255. Duplicate numbers would calculate the same subnet twice, and a negative or oversized slot would fail during evaluation. If your address plan reserves some slots for other tiers, narrow the allowed values to match that documented plan.

Use a variable validation for rules that can be evaluated from the inputs alone. Use a resource precondition when the rule depends on another resource value, and use a test assertion when you want a readable regression check for the result. Each layer has a different purpose: validation rejects bad inputs, preconditions guard resource operations, and tests document expected behavior for maintainers.

Add a mocked provider test

Create tests/network.tftest.hcl. command = plan prevents real apply behavior, while mock_provider supplies a fake AWS provider schema and computed values.

mock_provider "aws" {}

run "creates_expected_subnet_layout" {
  command = plan

  variables {
    vpc_cidr = "10.50.0.0/16"
  }

  assert {
    condition     = aws_vpc.network.cidr_block == "10.50.0.0/16"
    error_message = "The VPC must use the requested /16 range."
  }

  assert {
    condition     = length(aws_subnet.private) == 3
    error_message = "The module must plan three private subnets."
  }

Finish the run block with assertions for the address map:

  assert {
    condition     = aws_subnet.private["zone_a"].cidr_block == "10.50.0.0/24"
    error_message = "Zone A must keep subnet slot zero."
  }

  assert {
    condition     = aws_subnet.private["zone_b"].cidr_block == "10.50.1.0/24"
    error_message = "Zone B must keep subnet slot one."
  }

  assert {
    condition     = aws_subnet.private["zone_c"].cidr_block == "10.50.2.0/24"
    error_message = "Zone C must keep subnet slot two."
  }
}

These assertions check the evaluated Terraform configuration and planned resource values. They do not prove that a real account has free ranges, that AWS accepts every setting, or that a route works end to end.

Add a second run to check that the exported contract contains unique ranges:

run "exports_unique_subnet_ranges" {
  command = plan

  variables {
    vpc_cidr = "10.50.0.0/16"
  }

  assert {
    condition     = length(toset(values(output.private_cidrs))) == length(output.private_cidrs)
    error_message = "Every subnet must have a distinct CIDR."
  }
}

Keep the condition explicit enough that a failure explains what changed. A test that only checks a resource count can still pass after every subnet is assigned the wrong CIDR.

Test invalid input explicitly

Append another run to the test file to ensure the module rejects a prefix with the wrong size. expect_failures tells Terraform that the variable validation error is the expected outcome for this run.

run "rejects_wrong_vpc_prefix" {
  command = plan

  variables {
    vpc_cidr = "10.50.0.0/20"
  }

  expect_failures = [var.vpc_cidr]
}

This protects the module’s /16 design assumption. If you later allow other prefix lengths, update the subnet calculation and write explicit tests for each supported size instead of deleting the input guard.

Also exercise the new subnet number validation with a duplicate slot. Add another expected failure run using the same map for two zones:

run "rejects_duplicate_subnet_slots" {
  command = plan

  variables {
    vpc_cidr = "10.50.0.0/16"
    subnet_numbers = {
      zone_a = 0
      zone_b = 0
    }
  }

  expect_failures = [var.subnet_numbers]
}

This test makes the allocation rule visible to anyone changing the module. If a team intentionally needs identical prefixes in separate isolated networks, those networks should be separate VPCs with independent address scopes, not duplicate subnet slots in the same VPC.

Terraform unit and integration checks cover different failure typesMocked module testinput validation and CIDR mathCloud integration testpermissions, quotas, and routesRun inexpensive checks on every change, then test real resources in an isolated account.

Run the tests locally

Install Terraform 1.7 or later, then initialize without a backend and run the test command:

terraform init -backend=false
terraform fmt -recursive
terraform validate
terraform test

The first run downloads the AWS provider but does not need AWS credentials. A passing result should show each named run as successful. Commit .terraform.lock.hcl after initialization so collaborators install the same provider selection.

When a test fails, read the failed run and assertion name before looking at provider details. An input validation failure is expected only in the run that lists that variable in expect_failures. If a valid plan unexpectedly needs AWS credentials, confirm that the test file contains mock_provider "aws" and that the plan run does not select a real provider alias. Keep mock configuration and test files in the same root module directory as the resources they evaluate.

Use terraform test -verbose when you need more detail about run evaluation. It can help identify which expression or validation failed, but it does not turn a mocked test into a real cloud test. Do not put secrets in test variables because Terraform may include values in diagnostic output.

Add the test to continuous integration

Add a credential free job to your pull request workflow. It should check out the repository, install the Terraform version constrained by the module, then run the same commands. Do not configure AWS access keys or an OIDC role for this mock test job.

- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v4
  with:
    terraform_version: 1.13.3
- run: terraform init -backend=false
  working-directory: network-module
- run: terraform fmt -check -recursive
  working-directory: network-module
- run: terraform validate
  working-directory: network-module
- run: terraform test
  working-directory: network-module

Terraform’s provider mocking guide documents the mock syntax and its version requirement. Keep a separate integration test for an isolated AWS account when validating permissions, provider behavior, and routing.

For a repository with several modules, run each module’s test suite from its own directory or use a small matrix whose paths are listed in version controlled workflow code. A module test should not automatically discover and apply every example folder in the repository. Keep the workflow path filter aligned with the module so a documentation change does not unexpectedly run a credentialed deployment job.

Understand what a passing test means

A mocked test is strongest when it checks a module contract that can be derived from inputs: expected network blocks, required tags, resource counts, or invalid variable combinations. It is not a simulator for AWS quotas or connectivity. Keep production safety checks in plans, cloud policy, and post deployment verification.

Use a layered test strategy. Run formatting and validation first because they are cheap and produce fast feedback. Run mocked tests on every change to check calculations and module interfaces. Run a small integration test in a disposable AWS account when you change provider behavior, IAM permissions, route tables, or subnet associations. Finally, inspect a normal Terraform plan for the target environment and require a reviewer to approve changes with real impact.

Keep integration resources short lived and make cleanup part of the workflow. A failed test can leave resources behind if the cloud API timed out during teardown. Tag test resources with the run ID, alert on aged test resources, and never point a test at a production state file. Mocked tests reduce the number of cloud operations, but they do not remove the need to plan and review actual changes.

Add one assertion for every network invariant your module promises, then run the mock tests on each pull request. Use a separate isolated cloud account for the smaller set of checks that need real infrastructure.

Frequently Asked Questions

Can terraform test run without AWS credentials?
Yes. Terraform 1.7 and later can mock providers. A test that uses `mock_provider "aws"` can plan against generated values without authenticating to AWS or creating cloud resources.
Does terraform test create real infrastructure?
It can. Tests use apply by default, so use `command = plan` and a mocked provider for unit style checks that should not create resources.
Which Terraform version supports provider mocks?
Provider mocking for `terraform test` is available in Terraform 1.7 and later. The testing framework itself was introduced in Terraform 1.6.

Stop reaching for a spreadsheet

Obelinf keeps every subnet, device, circuit, and rack in one live source of truth, with audit logs and a topology view. Free for personal use.

Related Articles