Terraform Tests and Mock Providers: Verify Module Contracts Without Cloud Applies
Build repeatable Terraform module tests with plan and apply runs, provider mocks, input matrices, assertions, and explicit controls for real infrastructure.
Terraform configurations are executable infrastructure programs. A change can remain syntactically valid while selecting the wrong resource name, changing a module output, breaking a custom condition, or requiring an unintended replacement. Terraform’s native test framework gives module authors a way to run plans or applies against a test-specific state and assert that the configuration behaves as intended.
Tests need a clear safety model. A test run defaults to an apply-style operation, and ordinary provider configurations can create real resources. A file ending in .tftest.hcl is not automatically a sandbox. Use a mock provider or an explicitly safe disposable account for tests that could apply, keep credentials least-privileged, and verify the test command’s behavior before adding it to CI. Plan-only tests avoid provisioning, but still need correct provider and input setup.
Test files, runs, and isolated state
Terraform discovers test files with .tftest.hcl or .tftest.json extensions in the configuration directory and its test directory. A test file can define test-level settings, provider configurations, variables, and one or more run blocks. Each run executes a Terraform operation against the configuration under test, and assertions evaluate named values from that configuration and, where appropriate, outputs from earlier runs.
By default, a run performs an apply operation. Set command = plan when the test is checking configuration logic that can be evaluated from a plan and should not create resources. Apply-style tests are useful when integration behavior genuinely matters, but they should be isolated from production accounts and must account for the costs, quotas, side effects, and cleanup behavior of real resources. Do not assume that running under a directory named tests makes credentials safe.
# tests/bucket.tftest.hcl
mock_provider "aws" {
mock_resource "aws_s3_bucket" {
defaults = {
arn = "arn:aws:s3:::generated-by-the-test"
}
}
}
run "uses_the_requested_name" {
command = plan
variables {
bucket_name = "example-test-bucket"
}
assert {
condition = aws_s3_bucket.this.bucket == "example-test-bucket"
error_message = "The bucket resource must preserve the requested name."
}
}
The test file uses the same resource and provider schema as the real configuration, but Terraform substitutes generated values for computed fields when a mock provider handles the operation. A mock is suitable for testing local decisions such as name construction, tags, conditional resources, and outputs. It is not evidence that an API call succeeds, that an IAM policy is sufficient, or that a provider-computed ARN has a particular real-world shape.
Unit-style tests and integration tests
Use plan runs as unit-style tests for decisions that should be determined before provisioning: whether a module creates a resource, which input reaches an argument, how a name is assembled, or whether a variable validation rejects invalid input. The test can assert a known value without contacting the remote API when a mock provider is used. This makes those checks fast and suitable for pull-request feedback.
Use apply runs only where the behavior depends on the provider or real infrastructure. An integration suite may validate a data-source result, a network path, or a cloud service interaction, but it should use a dedicated test account, unique names, explicit budgets, and a cleanup strategy. Keep integration tests separate from quick unit checks so that developers can understand why a workflow needs credentials and what it is permitted to create.
Provider mocks became available in Terraform v1.7.0; the test framework itself is available in Terraform v1.6.0 and later. Pin or otherwise control the CLI version in CI so a test file does not silently depend on syntax absent from a developer’s older binary. The lock file controls provider selections, but it does not select the Terraform CLI version.
Mocks generate plausible values for computed attributes but do not know the real provider’s semantic constraints for every computed field. If the module checks that an ARN matches a cloud-specific format, supply a realistic explicit mock default. If a provider marks an attribute as required, the test must still provide it. Treat generated data as convenient test input, not a faithful cloud simulation.
Arrange inputs as a contract matrix
Each run can supply a different set of input variables. Rather than testing only the happy path, construct a small matrix around the module’s contract: a normal production-like value, optional inputs omitted, optional inputs set to null, boundary values, and deliberately invalid combinations. Keep cases separate and named after the behavior they protect.
run "accepts_default_retention" {
command = plan
variables {
bucket_name = "example-default-retention"
}
assert {
condition = aws_s3_bucket.this.bucket == var.bucket_name
error_message = "The configured name must reach the resource."
}
}
run "accepts_explicit_retention" {
command = plan
variables {
bucket_name = "example-retention"
retention_days = 90
}
assert {
condition = aws_s3_bucket.this.tags["RetentionDays"] == "90"
error_message = "The requested retention period must reach the bucket tag."
}
}
The second test assumes the module maps its declared retention_days input to a RetentionDays bucket tag. An assertion should test an observable property, not merely restate an input without checking how the module uses it. For example, assert against a resource argument or a public output when the goal is to protect a module interface. Error text should name the violated expectation and be useful to the next engineer who sees a failing test.
Tests can check relationships across values: a resource should be omitted when a feature flag is false, a child module should receive the expected region, or an output should expose a stable identifier. Avoid brittle assertions against provider-generated random values unless the test overrides those values deterministically. A test that passes only because of an incidental mock default is likely to be noisy or misleading.
Test failure paths intentionally
Input validation and custom conditions are part of the interface. A test can declare expected failures for supported checkable objects so the test suite verifies that a bad input is rejected. Keep such tests narrow: an expected validation failure should not accidentally hide an unrelated provider error or a second broken invariant.
run "rejects_an_invalid_bucket_name" {
command = plan
variables {
bucket_name = "Contains Uppercase"
}
expect_failures = [var.bucket_name]
}
The name in expect_failures identifies the object whose condition should fail. A test passes only when the expected failure occurs. If a precondition halts planning before later assertions can evaluate, do not add assertions that depend on values the plan never produced. Separate validation failure tests from success-path tests so a useful error is not confused with an incomplete plan.
Test both sides of a conditional resource. If a for_each becomes empty under a feature flag, assert that the resource collection is empty, then assert that the enabled case contains the intended key or output. For resources that can be replaced, inspect planned change behavior in a dedicated fixture rather than relying on an unreviewed apply in a real environment.
Mocking providers and data sources responsibly
A mock_provider block can replace provider responses in tests without requiring cloud credentials. Its mock resource and data blocks can supply defaults for selected computed attributes. Use deterministic values for fields referenced by assertions. Keep mocks minimal and intentional: an over-specified mock can merely repeat the implementation and miss a real defect, while an underspecified mock may generate data in a shape the configuration cannot use.
You can test different provider configurations by defining a real or mocked provider with an alias and mapping it in a run. That is useful for proving that a module selects the expected provider name, but it still cannot prove that credentials reach the intended account. A separate identity check and an integration plan are required for that operational guarantee.
Test fixtures and mock values are code. Review them for accidental secrets, production resource names, and undocumented assumptions. Do not copy a real state snapshot or a production credential into a test directory. If a test uses real infrastructure, make resource ownership, naming, cleanup, and access restrictions explicit in the test plan.
CI sequencing and evidence
Run formatting and validation before test execution, and fail early when the CLI or providers do not match the repository’s dependency policy. A typical unit-test job can run from a clean checkout with mocks and no cloud credentials. Keep a separate integration job behind deliberate credentials and environment approvals. Store concise logs and test results, but do not publish state or saved plan artifacts indiscriminately because they may contain sensitive values.
terraform fmt -check -recursive
terraform init -lockfile=readonly
terraform validate
terraform test
Use the exact Terraform CLI version approved for the repository. The test command can initialize providers and read files in the configuration, so its environment still matters. In CI, verify that test directories are included in code review and that no runner is pointed at a production backend by accident.
When a test fails, identify whether the failure is in parsing, initialization, provider schema, mock values, a condition, or an assertion. Preserve the run name and the failed expression in the report. Avoid responding by changing assertions to weaker conditions until the intended module behavior has been checked against source and plan output.
What a passing test can prove
A plan test with mocks proves a bounded property of the Terraform configuration under the inputs and mocked provider behavior in that test. It does not prove that the real cloud API accepts the request, that quotas are available, that credentials are authorized, that networking works, or that an apply is reversible. An apply test with a real provider proves more about one environment, but it still does not cover every region, account policy, or future provider response.
Treat tests as executable documentation for module guarantees. Use them to protect decisions that matter, keep their provider boundary explicit, and state exactly where mocks stop representing reality. A small suite of deterministic plan tests plus a carefully isolated integration test provides stronger evidence than either a large unreviewed apply suite or a collection of assertions that only repeat input values.
Related:
- Terraform Provider Aliases: Explicit Multi-Region and Multi-Account Boundaries
- Terraform State in Production: Backends, Locking, Drift, Import, and Recovery
Sources: