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.

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.
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.
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?
Does terraform test create real infrastructure?
Which Terraform version supports provider mocks?
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

Terraform CIDR Planning with Subnet Examples
Use Terraform cidrsubnet and cidrsubnets to divide a VPC range into stable subnet blocks, validate the result, and avoid renumbering deployed networks.
Read more
How to Use Terraform with IPAM for Network Subnets
Connect Terraform to IPAM with this hands on guide. Use Obelinf to find an approved subnet, read its CIDR safely, and plan an AWS network from it.
Read more
AWS VPC Design Best Practices: CIDR, Subnets, and Routing
Design an AWS VPC the right way: size the CIDR block, lay out public, private, and isolated subnets across availability zones, and wire route tables without common mistakes.
Read more