Testing Strategies
Once Molecule has applied the role, something has to decide whether the result is right, and Ansible gives you three layers for that: in-play assert and fail checks that guard a run from the inside, a Molecule verifier that inspects the converged target from the outside, and the idempotency rule that re-runs the whole thing and demands zero changed. The first two answer "is the end state what I expected"; the third answers "does the role actually settle."
With no state file to diff, that second run is the closest thing Ansible has to a plan that proves nothing is left to do. The three layers are not redundant — they catch different failures, and a serious role uses all three: the role defends itself, the verifier inspects it from outside, and the second run proves it converges.
assert / failverify.yml or testinfra — service running, port listening, config content present.changed. With no state to diff, the second run is the proof the role actually settles.assert and fail for In-Play Checks
ansible.builtin.assert evaluates a that: list of conditions mid-play and fails with a message if any is false; ansible.builtin.fail stops a run on a when condition you define. Use them to encode preconditions — "vault_db_password is defined and non-empty" — and postconditions — "the rendered nginx config contains the expected server_name" — so the role guards itself, not just the test suite in some other repo.
assert — a precondition and a postcondition the role enforces itself- name: Precondition — the vault DB password must be present ansible.builtin.assert: that: - vault_db_password is defined - vault_db_password | length > 0 fail_msg: "vault_db_password is unset — refusing to render a broken app.env" - name: Postcondition — the rendered vhost names the right server ansible.builtin.assert: that: "'server_name larkspur.io' in rendered_vhost.content | b64decode"
The value of putting these in the role rather than only in the test is that the role defends itself on every run, everywhere — in CI, in staging, and at 2am during an incident. A precondition that lives only in verify.yml guards the Molecule run; the same precondition as an in-role assert guards production too.
Molecule Verifiers
After converge, molecule verify runs your checks, and there are two real choices for what those checks are written in. One is an Ansible-playbook verifier — a verify.yml full of assert tasks, no extra language. The other is testinfra, a pytest-based framework whose assertions read the live host directly: host.service("nginx").is_running, host.socket("tcp://0.0.0.0:443").is_listening, host.file("/etc/larkspur/app.env").exists.
verify.yml asserts vs a testinfra test# verify.yml — Ansible verifier, no second language - name: nginx is running and 443 is listening ansible.builtin.assert: that: - ansible_facts.services['nginx.service'].state == 'running' # test_larkspur.py — testinfra, pytest reading the host directly def test_nginx_listening(host): assert host.service("nginx").is_running assert host.socket("tcp://0.0.0.0:443").is_listening
Ansible asserts vs testinfra
The Ansible verifier keeps everything in YAML the team already reads and gathers facts the Ansible way — no new language, no new test runner, the same mental model as the role itself. testinfra gives you pytest's richer assertions, parametrization, fixtures, and a host abstraction purpose-built for inspection. The trade is one-language simplicity against test expressiveness.
Default to the Ansible verifier and reach for testinfra when the asserts start fighting YAML — when you want parametrized cases across a dozen ports, or fixtures that set up and tear down inspection state, or assertions too awkward to express as a that: list. Until then, three assert tasks beat a pytest dependency and a second test language the team has to learn.
The Golden Idempotency Test
molecule idempotence runs converge a second time against the same target and fails the build if any task reports changed. A second-run changed means a task is not checking state before acting — almost always a command or shell with no changed_when or creates, or a template that re-renders volatile content like a timestamp. It is a real defect, not a cosmetic one, because the spurious changed can fire a handler — an nginx reload — on a run that should have been a silent no-op.
This is the Ansible-native correctness check the whole chapter leans on. There is no state file to diff and no plan to read; the second run is the proof that the role settles, and a non-zero changed on it is the closest Ansible gives you to a failing assertion about convergence itself.
What to Verify on the Larkspur Web Role
Verify the contract the role promises, not every incidental file. For larkspur_web that means: the services are up — nginx and gunicorn active and enabled — the right ports are listening (443), the rendered config files are present with the content you expect, and the app user and the /opt/larkspur release are in place. Assert what the role claims to deliver, so a failed verify points at a real broken promise.
Checking only that a file exists, without checking its content, is the trap. A template that renders an empty or wrong app.env still creates the file, so an existence-only check passes while the app boots misconfigured. Verify the content that matters and skip the incidental filesystem trivia, so the test fails for reasons that point at the contract rather than at noise.
Ansible verifier — a verify.yml built from ansible.builtin.assert tasks. No second language, reuses fact-gathering, and reads like the role it checks. Ideal when the team lives in YAML and the checks are simple state assertions: service running, port listening, config content present.
testinfra — pytest plus a host-inspection API that reads the converged host directly. Richer assertions, parametrization, and fixtures. Choose it when verification gets complex enough that pytest's structure pays for the extra dependency. Default to the Ansible verifier and reach for testinfra when the asserts start fighting YAML.
- Adding
changed_when: falseto a non-idempotentcommandjust to silence the idempotency failure, which does not fix the task — it hides that the command runs every time and any handler it notifies fires every time too. - Writing verifiers that only check "the file exists" and never its content, so a template that renders an empty or wrong
app.envpasses verify while the app boots misconfigured. - Putting preconditions only in the test and not in the role via
assert, so the role itself runs happily with an undefinedvault_db_passwordand writes a broken config the test in a different repo never guards against. - Treating a single spurious
changedon the second run as cosmetic and merging anyway, then discovering that task reloads nginx on every scheduled run across all six prod hosts. - Reaching for
testinfrafor trivial up-and-listening checks and taking on a pytest dependency and a second test language when threeasserttasks would have done it.
- Encode the role's contract with
assertinside the role — preconditions on required vars, postconditions on rendered output — so the role defends itself on every run, not only under Molecule. - Make
molecule idempotencea hard gate and fix the underlying task — add acreatesorchanged_whenguard, or switch offcommandto a real module — rather than masking thechanged. - Verify the behavior the role promises — service running, port listening, config content correct — not incidental filesystem trivia, so a failed verify points at a real broken contract.
- Start with the Ansible
assertverifier and adopttestinfraonly when verification complexity, like parametrized cases or host fixtures, justifies the extra framework.
Knowledge Check
A task reports changed on the second converge run. What does that actually indicate, and why is it a defect?
- The task isn't checking state before acting — it does work that should already be done, and the spurious changed can fire a handler like an nginx reload on a no-op run
- Molecule's container drifted on its own between the two back-to-back runs, which is expected behavior and harmless
- The role converged the host correctly, since a
changedon the second pass is always a sign of success - The verifier and the role's tasks disagree about the end state on the second pass, which points to a bug in the assertions inside
verify.ymlrather than any defect in the role itself
What is the difference between an in-play assert and a Molecule verifier?
- An in-play assert guards the run from inside the role on every apply; a verifier inspects the converged target from outside, after converge
- They are the same mechanism called by two different names, one in the role and one in the Molecule scenario
- An in-play
assertonly works inside CI, whereas a Molecule verifier only works when run on a developer laptop - A Molecule verifier runs before the converge step to define the desired end state, and the in-play
assertruns only afterward to confirm the role reached it
When does testinfra win over the Ansible assert verifier, and when does the Ansible verifier win?
- testinfra wins when checks get complex enough to need pytest's parametrization and fixtures; the Ansible verifier wins for simple state checks the team can read in YAML
- testinfra always wins on every role without exception, because pytest collects and caches host facts measurably faster than the Ansible verifier's own gathering ever can on the same target
- The Ansible verifier always wins because testinfra is unable to read service, port, or file state from the host
- It never matters which you pick, since the two verifiers produce byte-identical results for every possible check
Why is adding changed_when: false the wrong fix for an idempotency failure?
- It silences the report without fixing the task — the command still runs every time, and any handler it notifies still fires every time
- The
changed_whendirective is deprecated and already slated for removal in a futureansible-corerelease, so relying on it now guarantees a broken play later - It only works on real fully-fledged modules and never on a bare
commandorshelltask, so it is silently ignored and has no effect on the run at all - It makes the underlying task execute twice as often on each subsequent run, a second pass added to compensate for the spurious change it suppressed
You got correct