Terraform Pro Test Runs — the output that is still the last apply
If the run did not apply, the output did not move.
<!-- hal:authoritative:yaml -->
§I — Frame
Associate-003 is closed. Two Pro fires on this arc opened remote operations and plan-time results. 07-31 opened terraform validate, custom conditions, check blocks, and a first terraform test. Lab 07 is that first test. This visit is Lab 29.
Lab 29 ships a test that already looks grown-up. A run "setup_apply" with command = apply writes v1. A run "updated_release_plan" with command = plan feeds v2 and asserts output.service_state.value.version == "v2". The README asks for a setup apply, sequential runs, and output verification. The file as filed will fail, or it will pass for the wrong reason.
The assert reads an output. A plan does not write outputs. The output is still v1.
Coin it: the output that is still the last apply.
This is not 08-15. A speculative plan on HCP is a plan that must not apply, in a remote workspace. Today's plan is a terraform test run block that must not be mistaken for an apply. This is not 08-18. Nobody is force-unlocking. This is not 07-31. Nobody is learning check for the first time. The new fact is sequential runs, and what output means after each command.
The cloud overlay is Azure Storage, the same account Ops and Dev use. Figure 9.32 is the sample. A plan that flips public_network_access_enabled leaves primary_blob_endpoint alone. A test that asserts the endpoint on a plan run is asserting last apply. A test that asserts the switch on a plan run, against output rather than against the planned resource, is the same trap with a more honest field.
§II — Domain foundations: a run is a command, not a mood
terraform test executes .tftest.hcl files. Each run block is a command against a state that belongs to the test, not to your laptop workspace. Runs in one file are sequential. State flows forward. Variables you set in a later run replace variables from an earlier run for that command. They do not rewrite the previous apply until a later run applies.
Two commands matter today.
command = apply writes. Resources are created or updated. Outputs are computed and stored. After the run, output.NAME is the applied value.
command = plan reads. It refreshes, it walks data sources, it computes a diff, it does not write. Brikman, Plan testing (Ch.9, PDF pp.544-546): plan executes the read steps and not the write steps. His ALB snippet prints arn = (known after apply) next to load_balancer_type = "application". After a first apply, a later plan that would replace the ALB still leaves terraform output holding the old ARN until someone applies.
terraform test exposes the same split inside the run file. output.NAME in a plan run is the output from the last apply that this test state has seen, or it is unknown if the output depends on a value the plan has not written. It is not "what the plan wishes the output would be."
A third command, command = apply again, is how a sequential file actually updates an output. Lab 29's README says "add a setup run using command = apply" and "verify outputs across sequential runs." The broken file already has the setup. What it is missing is an apply of v2, or an assert that does not use the output as if the plan had written it.
run blocks can also set plan_options and assert on resource attributes. terraform_data.service.input.version during a plan of v2 can be known, because input is a configuration value. terraform_data.service.output is known after apply. Lab 29's output is terraform_data.service.output. The assert asked a write-time value to appear in a read-time run.
§III — Lab 29, the broken file, the two honest fixes
The configuration under test:
resource "terraform_data" "release_marker" {
input = {
version = var.release_version
}
}
resource "terraform_data" "service" {
input = {
name = var.service_name
version = var.release_version
owner = var.external_owner
}
lifecycle {
create_before_destroy = true
ignore_changes = [input["owner"]]
replace_triggered_by = [terraform_data.release_marker]
}
}
output "service_state" {
value = terraform_data.service.output
}
The broken test:
run "setup_apply" {
command = apply
variables {
service_name = "checkout"
release_version = "v1"
external_owner = "platform"
}
}
run "updated_release_plan" {
command = plan
variables {
service_name = "checkout"
release_version = "v2"
external_owner = "platform"
}
assert {
condition = output.service_state.value.version == "v2"
error_message = "Expected service_state.version to reflect the updated release_version."
}
}
After setup_apply, output.service_state.value.version is v1. After updated_release_plan, the plan will replace terraform_data.service because release_marker changed. The output is still v1. The condition is false. That is the correct failure.
Fix one. Apply v2, then assert the output.
run "updated_release_apply" {
command = apply
variables {
service_name = "checkout"
release_version = "v2"
external_owner = "platform"
}
assert {
condition = output.service_state.value.version == "v2"
error_message = "Expected service_state.version to be v2 after apply."
}
}
This is the README's "verify outputs across sequential runs." Output after write.
Fix two. Keep the plan run, assert the plan, not the output.
A plan-run assert belongs on a known configuration value or on the planned change, not on output of a write-time attribute. var.release_version == "v2" is a tautology (you just set it). The useful plan assert is "this run wants a replace of terraform_data.service" or "the planned input.version is v2." The output stays out of the plan run.
A Pro question that offers "the plan run updates outputs so later runs can read v2" is offering 08-15's speculative-plan confusion in test clothing. Speculative plans do not write. Test plan runs do not write. Last apply remains last apply.
ignore_changes = [input["owner"]] is in the module so a later run can change external_owner without replacing service. A plan run that only changes owner should be empty of replaces. A plan run that changes release_version should replace, because release_marker is in replace_triggered_by. Assert those two plans separately. Do not use output.service_state to tell them apart. The output will still be v1 in both plan runs if you have not applied v2.
§IV — The same trap on Azure Storage
Figure 9.32's account, once LGS is LRS, exports (if you add them) primary_blob_endpoint and, if you are disciplined, public_network_access_enabled.
run "create_public" {
command = apply
variables {
public_network_access_enabled = true
}
}
run "plan_private" {
command = plan
variables {
public_network_access_enabled = false
}
assert {
condition = output.public_network_access_enabled == false
error_message = "Expected the output to show the planned private account."
}
}
This is Lab 29 wearing an Azure shirt. The output is still true. The plan is honest. The assert is not.
The honest plan-run assert is: the planned value of the resource attribute is false. The honest apply-run assert is: the output is false. The honest e2e assert is the one the Dev lesson writes in Go: GetProperties returns Disabled. terraform test does not call GetProperties. It can run a data "azurerm_storage_account" in a follow-up apply and assert that. That data source is a read of Azure, and it still sees last apply until the flip is applied.
Brikman's plan-testing tools table (p.545) lists Terratest, OPA, Sentinel, Checkov. None of them write. terraform test with command = plan sits in that row. terraform test with command = apply sits closer to his integration tests. Mixing the rows is the exam trap.
§V — Worked example: three runs, three asserts, one module
A passing Lab-29-shaped file for the Azure account looks like this.
run "create_public" {
command = apply
variables {
public_network_access_enabled = true
}
assert {
condition = output.public_network_access_enabled == true
error_message = "First apply must store the public grant."
}
}
run "plan_private" {
command = plan
variables {
public_network_access_enabled = false
}
assert {
condition = azurerm_storage_account.backup.public_network_access_enabled == false
error_message = "Plan must show the switch off in configuration."
}
assert {
condition = output.public_network_access_enabled == true
error_message = "Output during the plan run is still the last apply."
}
}
run "apply_private" {
command = apply
variables {
public_network_access_enabled = false
}
assert {
condition = output.public_network_access_enabled == false
error_message = "Only apply updates the output."
}
}
The middle run has two asserts on purpose. The first is the plan. The second is the coin. If a candidate deletes the second assert because "outputs should follow the plan," they have failed the Pro reading.
Do not add a check block here as the main lesson. 07-31 owns check blocks. A check that reads the resource attribute during a plan can pass against a live public account. Ops already named that. The Cert point is the run command, not the check keyword.
Lab 07 remains the place you send someone who cannot write a validation or a precondition yet. Lab 29 is the place you send someone who already can, and who still treats output as a preview.
Mocks and override_resource are a different Pro surface, and they are not today's lie.
override_resource / mock providers let a command = plan or even a command = apply run without talking to Azure. A mocked apply can update an output inside the test state without writing an account. That output is still "last apply" of the mock, not a preview of a plan. If the exam stem says the run used a mock, the output is whatever the mock apply stored. If the stem says the run was command = plan against a real (or mocked) prior apply, the output is still that prior apply. The mock changes who you talked to. It does not change the command.
Do not spend this fire on mock syntax. 07-31 did not open it. Lab 29 does not name it. Mention it only to refuse the escape: "I mocked it, so the plan updated the output" is still a fail.
State that belongs to the test file.
terraform test state is not your workspace state. A leftover terraform.tfstate in the module directory is a different file. Lab 29 is stateless in its metadata: the test creates and destroys its own state. If a candidate asserts against a workspace output they printed from terraform output in the module dir, they are reading a third last-apply, the laptop's, and Lab 29 never wrote it. Pro questions will offer that mix. The answer is "the run's state."
§VI — Connection to Today's Ops + Dev Lessons
Ops: a consumer terraform_remote_state is a read of a file the producer wrote. That file is last apply. The blob endpoint string does not move when the grant does.
Dev: terraform.Output is that file, compiled into Go. GetProperties is Azure. The third terratest fire exists because the output cannot carry the grant.
Cert: output.NAME inside a command = plan run is the same file again. The three lessons are one fact at three altitudes. State file. SDK. Test run.
08-15's speculative plan still cannot apply. Today's plan run still cannot write. The shared discipline is "read versus write," not "HCP versus local."
08-18's force-unlock is a burial. Today's failed assert is a diagnosis. Do not force-unlock a test state to make Lab 29 pass. Apply the second run, or change the assert.
§VII — Practice Questions
terraform test file applies a module with release_version = "v1", then runs command = plan with release_version = "v2" and asserts output.service_state.value.version == "v2". The output is sourced from terraform_data.service.output. What happens, and why?terraform_data.output is known after apply. A plan run does not write. The output is still v1 from the setup apply. This is Lab 29's broken updated_release_plan.run with command = apply and release_version = "v2", and put the output assert on that run. Sequential runs share test state. Apply writes. Then output is v2.terraform plan execute, and what does it not?primary_blob_endpoint and public_network_access_enabled. You plan a flip from public to private. Which output changes in terraform output before apply? Which one should a plan-run test assert?terraform output before apply. Both are last apply. A plan-run test may assert the planned resource attribute public_network_access_enabled == false. It must not treat either output as the preview. The endpoint string will not change even after apply.terraform test plan run is a local (or CI) test command against test state. Neither writes. The exam will name them separately. Mixing them is a zero.lifecycle.ignore_changes on public_network_access_enabled, applies once as private, then opens the account in the portal. terraform test plan is empty. terraform output still says false. What reader would fail, and which lesson owns it?terraform test without a data source cannot see the portal. An empty plan plus a last-apply output is how ignore_changes hides drift. 08-12 named the two-owner attribute; today the second owner is the portal.§VIII — Closing
A run is a command. Apply writes outputs. Plan reads them. Lab 29's broken assert treats a plan as a write. The output is still the last apply.
Name the command first. Then name the value. Then decide whether you are allowed to ask.
Examine well. If the run did not apply, the output did not move. That sentence sits the exam. Lab 29 is the drill. Figure 9.32 is the Azure shirt on the same drill. Wear that shirt on the exam today.
Related
- Prior arc: Pro remote operations and force-unlock (2026-08-18)
- Domain hub: Cross-References/domains/01-Earth-DevOps
- Grounding tome: Terraform: Up and Running (Brikman Ch.9, Plan testing) (Ch.9, pp. 544-546)
🫡 ⚖️ 📜 Leo.Syri — Praetor Consulate, Imperium Luminaura Filed 2026-08-21 · Fajr · sprint track TF day 30 · tenth TF visit · trio #96