Why Test and the Testing Pyramid
A role that configures production is code, and code is wrong until proven otherwise. The larkspur_web role decides what nginx, gunicorn, and the app config look like on every prod host; a bad when condition or a non-idempotent command ships a broken config to all six prod web hosts at once, with no plan to review before it lands. There is no separate artifact that says "this is what will happen" — the run itself is the change.
Ansible has no state file to diff against, so the question "did this run actually converge the box?" has only one honest answer: run it and check, then run it again and check that nothing changed. The testing pyramid orders the checks by cost — cheap static analysis at the bottom, a real throwaway target in the middle, full integration against something prod-shaped at the top — so the feedback you get most often is the feedback that is nearly free.
Automation Is Code
The larkspur_web role is not a script someone runs once and forgets. It is the definition of what every prod web host looks like — which packages are installed, what the rendered nginx vhost contains, which gunicorn socket is listening, what defaults the app config carries. A typo in a template or a wrong default in defaults/main.yml is a production change the moment the role runs, identical in consequence to a bug shipped in the application itself.
So the role earns the same testing rigor as the app it deploys, not a "it ran clean on my laptop" wave-through. The blast radius is the whole web tier at once, which is precisely the situation where you want a clean target, a verifier, and a gate standing between the change and production — the same machinery you would never ship application code without.
The Pyramid, Bottom to Top
The pyramid has three tiers, ordered by what they cost to run. At the bottom: syntax check and lint — milliseconds, no target needed, just static analysis of the files. In the middle: the idempotency check and a full Molecule run — seconds to minutes, against one throwaway container the test creates and destroys. At the top: full integration against a prod-shaped host — slow and expensive, because it stands up something close to the real thing.
The ordering is a budget. Run the cheap layers on every commit, because they cost nothing and catch the most common mistakes first; reserve the expensive layers for pre-merge, where a slow honest test is worth the wait. A lint failure that stops the job in milliseconds is strictly better than the same bug surfacing minutes into a Molecule run, so you put the fast checks where they fire most often.
changed. Seconds to minutes. Catches "the role doesn't produce the end state."The Second-Run Rule as the Cheapest Real Test
The Ansible-native correctness check is brutally simple: apply the role, apply it again, and the second run must report zero changed. A non-zero second run means a task is not checking state before acting and is not truly idempotent — it did work on the second pass that it should have recognized as already done. You catch that for the cost of one extra play, which is why it sits at the bottom of the pyramid as the cheapest test that measures something real.
This rule measures convergence, not completion. A play can finish green while a task quietly re-does its work every time, and only a second run exposes it. The spurious changed matters because it can notify a handler — an nginx reload, a service restart — on a run that should have been a silent no-op, so the defect is not cosmetic even when the end state looks correct.
No State to Diff, So You Re-Converge
Terraform proves correctness by running plan against a stored state file: it diffs desired config against the recorded reality and shows you what would change. Ansible has neither the plan nor the state — there is nothing to diff against, because the managed node itself is the only record of what is true. Convergence is therefore proven empirically: you re-run the role against the live machine and read the result.
That is why testing is not optional polish here — it is the only mechanism that tells you the role is right. With a provisioning tool you can read a plan before applying; with Ansible the equivalent reassurance comes from a clean converge on a fresh target followed by a zero-changed second run. The re-convergence is the test, and skipping it leaves you trusting a green run that proves only that the playbook finished.
What Each Layer Catches
Each tier fails for a different reason, which is why skipping one leaves a whole class of bug uncaught. Lint catches anti-patterns and deprecations before a target is even touched — a deprecated module call, a bare command where a real module exists, a missing FQCN. Molecule catches "the role does not actually produce the end state" by applying it to a clean box and inspecting the result. Integration catches "it works on a clean container but not on a real, drifted host," where a package already half-installed or a service in an odd state breaks an assumption the container never had.
Because the failure modes do not overlap, the layers are additive rather than redundant. A clean lint says nothing about whether the role converges a box; a green Molecule run says nothing about a drifted production host; and neither catches a deprecation that lint would have flagged in milliseconds. Run all three and each closes a gap the others leave open.
- Treating "the playbook finished without an error" as proof of correctness — a play can exit green while silently doing nothing useful, like a skipped task or a no-op
command, which only a verify step or a second-run check exposes. - Running only the expensive integration layer and skipping lint, so a deprecated module call or a missing FQCN blows up minutes into a slow CI job instead of in milliseconds at the bottom of the pyramid.
- Looking for a "what will change" preview as strong as
terraform planand trusting--checkas if it were one — check mode is best-effort and lies for anycommandorshelltask, so it supplements the second-run test rather than replacing it. - Writing the role and its tests together and calling a green run on the same box that wrote the files a pass — testing a role against the state it just created proves nothing; you need a clean throwaway target it did not configure.
- Skipping the second-run check because the first run looked right, then shipping a non-idempotent task that fires a handler on every scheduled run across the whole fleet.
- Make the second-run zero-
changedrule the non-negotiable floor for every role, since it is the cheapest test that measures convergence rather than mere completion. - Put fast static checks — syntax, yamllint, ansible-lint — on every commit and reserve Molecule and integration for pre-merge, so the feedback you get most often is the feedback that is nearly free.
- Test each role against a clean target it did not configure, because a role that is green only on a pre-warmed host is untested for the case that matters: the first apply to a fresh node.
- Treat a non-idempotent task as a failing test, not a cosmetic wart, because every spurious
changedis a handler that may fire on a run that should have been a no-op. - Run all three pyramid layers rather than just one, since each fails for a different reason and skipping a layer leaves its whole class of bug uncaught.
Knowledge Check
A playbook run exits green with no errors. Why is that not proof the role converged the host?
- A play can finish green while doing nothing useful — a skipped task or a no-op command — so completion is not the same as convergence
- A green run only confirms that the SSH transport reached the managed host and that fact-gathering succeeded, not that any actual task in the play body ever ran
- Ansible swallows task errors by default unless you set
any_errors_fatal, so a green result is meaningless - The playbook exit code is assigned at random and bears no relation to which tasks actually passed or failed
What does the second-run rule actually measure, and why is it the cheapest real test?
- Convergence — a zero-changed second run proves every task checks state before acting, caught for the cost of one extra play
- Speed — it times how fast the role re-applies against an already-warm host and flags any second run that comes out slower than the very first one
- Coverage — it counts how many of the role's tasks were exercised at least once across the two passes
- Syntax — it re-parses and re-validates every YAML file a second time to confirm nothing was malformed
Why does Ansible re-converge against the live host instead of diffing a state file the way Terraform does?
- Ansible keeps no state file — the managed node is the only record, so correctness is proven empirically by re-running
- Ansible's local state file is vault-encrypted at rest, so it cannot be diffed without the vault password
- Diffing a stored state file is slower than a fresh apply, so Ansible skips the diff purely for speed
- Ansible stores its state in a remote backend that only the cloud provider's own API is able to read and diff on your behalf during a run
Which pyramid layer catches a deprecated module call, and which catches a non-idempotent task?
- Lint catches the deprecation before a target is touched; the idempotency check at the Molecule layer catches the non-idempotent task
- The integration layer catches both the deprecation and the non-idempotent task on its own, making the cheaper lint and Molecule layers beneath it entirely optional to run
- Lint catches both of them, since it reads every task statically and can tell which ones will report a spurious change on a second run
- Neither layer catches them at all — only a careful manual review reading the play against the real production host can ever surface either one
You got correct