Git, CI/CD & GitOpsLIV · Infrastructure Testing StrategyUnitAndModule
Unit and module tests — terraform test and Molecule in the pipeline
What you'll learn
- Write a terraform test file for a Terraform module using the 1.6+ native test framework
- Run a Molecule scenario as a per-PR unit test for an Ansible role
- Identify the class of mistake unit and module tests catch — module contracts, default values, idempotency — and the class they deliberately leave to integration
- Estimate the wall-clock cost of a focused unit-and-module test corpus
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
The static layer catches syntactically invalid files. The policy layer catches policy-violating files. Both leave a gap: a file that is syntactically valid and policy-conformant can still misbehave when applied. The unit and module test layer closes part of that gap by exercising the configuration in isolation — without the live cloud, but with enough realism to catch contracts, defaults, and idempotency mistakes that no static rule could see.
Terraform test (1.6+)
Terraform 1.6 introduced a native test framework, terraform test. The framework runs .tftest.hcl files alongside the module they exercise, planning and applying the module against mocks or against local-only state, and asserting on the result. It is the canonical way to test a Terraform module’s contract — its inputs, its outputs, and the resources it produces — without provisioning real cloud resources.
A canonical test file for an S3 bucket module:
run "plan_with_defaults" {
command = plan
assert {
condition = aws_s3_bucket.example.encryption_configuration[0].rule.apply_server_side_encryption_by_default[0].sse_algorithm == "AES256"
error_message = "S3 bucket must default to AES256 server-side encryption"
}
}
run "plan_with_versioning_disabled" {
command = plan
variables {
versioning_enabled = false
}
assert {
condition = aws_s3_bucket.example.versioning[0].enabled == false
error_message = "versioning_enabled=false must disable bucket versioning"
}
}
The run blocks execute terraform plan with the named variables and assert on the planned resource attributes. The assertions fail the test if they do not match. The cost is seconds per run — no cloud calls, no apply, no destroy.
terraform test is scoped to:
- Default-value contracts. A module’s default values produce the expected resources and resource attributes.
- Input/output contracts. Variables typed correctly, outputs present with the expected types, conditional resources created or not created based on input flags.
- Reference resolution across modules. A module that composes other modules plans successfully against the mocked state.
- Provider-specific behaviour. Where the provider supports it, the test asserts on attributes that the cloud would compute (e.g. an auto-generated ARN pattern).
The test is not scoped to:
- Whether the cloud accepts the plan. The static and policy layers catch the syntactic and policy violations; the next layer (disposable integration) catches the cloud-side rejections.
- Whether the resource actually works in production. A test that asserts “the bucket exists and has encryption” in
terraform testis asserting on the plan, not on the live bucket.
Molecule as a unit test
Molecule is the canonical unit-and-module test framework for Ansible roles. In the unit-test role, Molecule converges the role against an ephemeral host (typically a container with the docker driver), asserts on the system state with testinfra or ansible.builtin.assert, and tears the host down. The full lifecycle was covered in LI-05; this lesson is the framing of Molecule as the third layer of the testing pyramid rather than the fourth.
In unit-test framing:
- The driver is docker or podman, fast and hermetic.
- The platforms are the distributions the role targets (one per entry in
molecule.yml). - The verify stage is non-trivial: it asserts the system state, not just the absence of errors.
A minimal verify step, asserting that an nginx role installed the right package and started the service:
- name: Verify nginx is installed and running
ansible.builtin.assert:
that:
- ansible_facts.packages['nginx'] is defined
- ansible_facts.services['nginx.service'].state == 'running'
- ansible_facts.services['nginx.service'].status == 'enabled'
fail_msg: "nginx is not installed, running, and enabled"
A single molecule test runs the full lifecycle in minutes. For per-PR use, it is fast enough; for nightly, it is trivial.
flowchart LR
A["role/"] --> B[molecule test]
B --> C[create container]
C --> D[converge role]
D --> E[idempotence - second converge]
E --> F[verify system state]
F --> G[destroy container]
The cost is in minutes, dominated by the container start and the role converge. The value is the catch of a class of mistake no static analyser can see: a role that installs the wrong package, restarts the wrong service, writes the wrong configuration file, or fails idempotency on the second converge.
What unit and module tests catch
The third layer, with terraform test and Molecule, is scoped to:
- Module contracts: the right resources with the right attributes for the right inputs.
- Default-value mistakes: a default that produces a misconfigured resource when no variable is overridden.
- Role idempotency: a role that converges correctly the first time and produces no changes on the second run.
- System-state assertions: the package is installed at the expected version, the service is enabled and running, the configuration file has the expected content.
- Reference resolution across modules: a module that composes other modules plans and outputs without
nullvalues or cycle errors.
The layer is not scoped to:
- Whether the live cloud accepts the plan. That is the disposable integration layer.
- Whether the IAM policy evaluates correctly. That is the disposable integration layer.
- Whether the DNS resolves. That is the disposable integration layer.
- Real-user behaviour. That is the staging and production validation layer.
The cost
Unit and module tests are cheaper than disposable integration but more expensive than static and policy. The order-of-magnitude cost:
terraform test: seconds per run. No cloud, no network. The full corpus should run in under a minute.molecule test: minutes per scenario. Container start, role converge, idempotence, verify, destroy. A single scenario per role, with a small verify step, runs in 3-5 minutes.
The corpus should be small and high-value. Every test costs time; the value is proportional to the class of mistake it catches. A terraform test run that exercises every input permutation is over-testing; a terraform test run that exercises the contracts the role depends on is right-sized.
How the layer composes
In a CI pipeline:
- Static gates (
terraform fmt -check,terraform validate,ansible-lint,yamllint) on every PR. - Policy gates (
tfsec,checkov,conftest) on every PR. - Unit and module gates (
terraform test,molecule testwith docker driver) on every PR. - Disposable integration (Terratest against a real cloud account,
molecule testwith a cloud driver) on a schedule or pre-merge. - Staging and production validation (smoke, canary, drift) per change.
The unit and module layer is the last gate that runs on every PR; everything above it is scheduled or pre-merge. That is the property the pyramid shape encodes.
Production discipline
- Every Terraform module has a
terraform testfile with at least onerunblock. A module without tests is a module that has not been verified. - Every Ansible role has a Molecule scenario with a non-trivial verify step. A role without a scenario is a role that has not been verified.
- The unit and module corpus is small. A test for every input permutation is over-testing; a test for the contracts the role depends on is right-sized.
- The unit and module tests run on every PR. They are the last cheap layer.
terraform testis not a substitute forterraform planagainst a real backend. The static and plan gates cover planning;terraform testcovers contracts.
Cross-course references
- Terraform for Production Sysadmins - Part XXI (ModulePatterns) covers the module patterns that
terraform testexercises. - Ansible for Production Sysadmins - Part XXVI (Testing) covers Molecule scenarios in depth.
- This course, Part L (TerraformCI) - lesson
git-cicd-gitops-l-02-fmt-and-validateis the static layer; this lesson is the unit-and-module layer. - This course, Part LI (AnsibleCI) - lesson
git-cicd-gitops-li-05-molecule-and-integration-testingis the Molecule lifecycle in depth; this lesson is the pyramid framing.
Quiz
Knowledge check · 4 questions
Q1. A team adopts terraform test for their S3 bucket module. Which class of mistake does terraform test catch that `terraform validate` does not?
Q2. A Molecule scenario with a converge stage and an idempotence stage but no assertions in the verify step is a complete unit test for the role.
Q3. Name two classes of mistake that the unit and module test layer is scoped to catch, and two classes it is scoped to leave to the disposable integration layer.
Q4. Diagnose why the unit and module layer let a misconfiguration reach production, and identify what the disposable integration layer should have caught.
A team writes a Terraform module for an S3 bucket and a Molecule scenario for an nginx role. terraform test passes (the default values produce a bucket with AES256 encryption). Molecule passes (the role installs nginx and starts the service). The change is applied to production. In production, the bucket's encryption is AES256, but a downstream IAM policy that allows the team's data pipeline to read the bucket is denied because the policy's resource ARN does not match the bucket's actual ARN format. The pipeline stalls for an hour before the team discovers the mismatch.
Passing score: 75%. Answers are checked in this browser.