Terraform Module Testing as Governance: The Part That Actually Enforces the Rules

Over the last few weeks this series has built up a fair set of rules. Module boundaries decide what each module is responsible for. Calling conventions decide how those modules get pinned, what goes in and what comes out. Pinned sources, typed inputs, validated variables, outputs treated as a contract, providers passed down rather than hidden inside.

All of it is sensible. None of it enforces itself.

A convention that lives in a blog post, a wiki page or one senior engineer’s head is a suggestion. It holds right up until someone’s in a hurry, the reviewer is skimming a pull request at half four on a Friday, and an output quietly disappears in a “tidy up” commit. Nobody did anything wrong, exactly. Nothing stopped them either.

That’s the gap testing fills, and it’s why I think of Terraform testing as governance rather than a nice-to-have.

I’ve already covered the mechanics in Testing Terraform Modules in Azure: the .tftest.hcl syntax, plan vs apply mode, expect_failures, and wiring it all into an Azure DevOps pull request pipeline. I won’t repeat that here. This post is about what to test once you’ve got boundaries and conventions worth protecting, and how to make sure the tests actually get a say.

Test the contract, not just the resource

The examples in that earlier post tested what a module builds. Prod gets GRS, every storage account enforces HTTPS. That’s still worth doing, but it’s only half the picture.

The other half is testing what the module promises its callers. Last week I said a module’s inputs and outputs are its API. If that’s true, they should be tested like one.

Inputs are the easy part, because validation is already doing the work. The test just proves the validation is still there and still rejects what it should:

run "rejects_unknown_environment" {
  command = plan

  variables {
    name        = "orders-api"
    environment = "production"
  }

  expect_failures = [var.environment]
}

If someone loosens or removes that validation, the module doesn’t fail. The test does.

Outputs get less attention, which is exactly why they need it. Remember the one() convention from last week, where the golden path returns null for the database ID when the database is disabled, rather than failing on an index that isn’t there? That’s a promise to every caller, so test it:

run "database_disabled_returns_null_id" {
  command = plan

  variables {
    name        = "orders-api"
    environment = "dev"

    database = {
      enabled = false
    }
  }

  assert {
    condition     = output.database_id == null
    error_message = "database_id must be null when the database is disabled."
  }
}

This test does two jobs. It checks the behaviour, and because it references output.database_id directly, it fails if someone renames or removes that output. A test that references an output is a test that notices when the output goes missing, which is exactly the breaking change that’s otherwise invisible until a consumer’s plan falls over.

A contract test you had to change is a breaking-change signal

This is the part that turns testing from quality control into governance.

Last week I said removing an output or a variable is a breaking change and should get a major version. That’s easy to say. In practice, the person making the change often doesn’t realise they’ve broken anything, so the version bump is a judgement call made by the one person least placed to make it.

Contract tests take a lot of that judgement out of it.

If an existing contract test has to be changed or deleted because the interface or behaviour it protects is deliberately changing, that’s a strong signal that you’ve made a breaking change and should be thinking about a major version.

Passing every existing test doesn’t prove a change is non-breaking. Tests are only as complete as the contracts you’ve captured. But having to weaken or remove an established contract test should make everyone stop and ask why.

That doesn’t make the decision for you. Sometimes the breaking change is the right call. What it does is force the conversation to happen in the pull request, with the test diff sitting there in plain sight, instead of in an incident channel three weeks later.

Test it the way consumers actually call it

Testing a module in isolation proves it works when called the way you expected. It doesn’t prove it works when called the way your consumers actually call it.

terraform test lets a run block point at a different module, so you can keep a small fixture that calls your module the way a real consumer would:

# tests/fixtures/consumer/main.tf
module "web_app" {
  source   = "../../.."
  for_each = toset(["orders-api", "billing-api"])

  name        = each.key
  environment = "dev"
}
# tests/consumer.tftest.hcl
mock_provider "azurerm" {}

run "callable_with_for_each" {
  command = plan

  module {
    source = "./tests/fixtures/consumer"
  }
}

This looks trivial, but it’s quietly enforcing one of last week’s conventions. A module that contains its own provider block can’t be called with count, for_each or depends_on. If anyone ever adds one, this test fails immediately, rather than the first time a team tries to deploy two of something.

The mock_provider block is worth a mention too. It means this test runs without Azure credentials or access to a subscription, so there’s no reason it can’t run on every pull request.

The upgrade test

This series started with the AzureRM v5 breaking changes, and this is where testing earns its keep.

When you bump the provider version in a module repo, terraform init -upgrade pulls the new provider, and the same test suite runs against its schema.

Mock providers still use the real provider’s schema, so schema-level breaking changes get caught at plan. The address_space change from List to Set is a good example. Any code indexing address_space[0] stops working, because you can’t index a set, and the test run tells you before any consumer finds out.

We’ve already been bitten by this kind of thing in AzureRM v4. We eventually had to pin to an exact version because later releases changed things in resources we were using and broke modules that had previously planned cleanly. A provider upgrade stopped being something I’d trust just because the major version hadn’t changed.

What mocks won’t catch is a change in behaviour.

The resource_provider_registrations default change from the opener only shows up when a real provider talks to a real subscription. So the pattern I’d use is mocks on every pull request, and a run against a sandbox subscription before cutting a release that includes a significant provider upgrade.

That gives you two different layers of protection. The mock tests tell you whether your Terraform still works against the provider’s configuration schema. The sandbox test tells you whether it still behaves the way you expected once Azure gets involved.

Some conventions aren’t tests

Not everything from last week belongs in terraform test, and it’s worth being honest about that.

“No module call ever points at a branch” is a good example. terraform test exercises what a module does, it doesn’t inspect how a consumer’s code references it. That’s a job for static analysis.

TFLint’s terraform_module_pinned_source rule flags Git module sources without a pinned ref, and setting its style to semver goes further and requires a semantic-version ref rather than allowing an arbitrary branch or ref.

And as I said in the earlier post, terraform test isn’t security scanning or policy enforcement either. An NSG rule allowing 0.0.0.0/0 is still a job for something like Checkov. What’s allowed to exist in a subscription is still a job for Azure Policy.

Testing covers the gap between them: whether the module still behaves the way it promised.

Making Terraform testing governance, not good intentions

A test suite nobody is required to pass is just documentation that runs. Three things make it enforceable.

The tests have to block the merge. In Azure DevOps that’s a build validation branch policy on main. In GitHub it’s a required status check in a ruleset. If the test can fail and the pull request can still be merged, it’s advisory.

The tests have to be protected. If the person changing the module can also quietly change the test, you’ve moved the problem rather than solved it. In the earlier post I used an Automatically Included Reviewers policy with a path filter on the test files. In GitHub, the equivalent is a CODEOWNERS entry for the tests/ folder, with code owner review required in the ruleset.

Weakening a test needs the same approval as the change it allows. This is the rule I’d write down if I could only write down one. Deleting an assertion to get a pull request through is a design decision, and it should be reviewed like one.

The interesting bit here is that none of this stops people making breaking changes.

Nor should it.

Sometimes an interface needs to change. Sometimes an output should disappear. Sometimes a provider upgrade forces your hand.

Governance shouldn’t mean making change impossible. It should mean making deliberate change obvious. It’s the same argument I made in When Guardrails Become Walls, just applied to modules rather than policy.

Pulling it together

Boundaries decide what each module owns. Calling conventions decide how modules talk to each other. Testing is what makes either of them true on a Tuesday afternoon when nobody’s thinking about them.

None of this needs a new language or a heavyweight framework. It’s HCL files sitting next to the module, a fixture or two that behave like real consumers, and a pipeline that won’t let anything through without them.

That’s a small investment for turning “we agreed we’d do it this way” into “you have to make a conscious decision to do it differently.”

Next week I’ll close the series by bringing the whole lifecycle together: how a Terraform module moves from design, through testing and review, into a versioned release that consumers can actually depend on.

Your subscription could not be saved. Please try again.
Your subscription to deniscooper.co.uk has been successful.

Join Cloud Engineers Who Build Smarter on Azure

Free checklists, practical guides and the latest posts — built for cloud engineers working with Azure and Terraform.

Leave a Comment