This is the fifth and final post in our Ansible fundamentals series. It builds on everything from inventory basics through roles, variables, and Vault.
We introduced idempotency back in the first post in this series — the property that running a playbook a second time should report no changes, because the system is already in the desired state. What we didn't cover is how you actually verify that property holds, rather than just assuming it does because the playbook ran without an error. Because this is genuinely the highest-leverage practice in this entire series — the thing that turns "it worked on my machine" into something you can trust in production — it's worth going deep here.
Why "it ran successfully" isn't the same as "it's correct"
A playbook can complete with zero errors and still not be idempotent. The most common cause is a shell or command task with no built-in concept of "already done":
- name: Add a line to a config file
ansible.builtin.shell: echo "max_connections=200" >> /etc/app/app.confRun this once, and it works. Run it again, and it appends the same line a second time — technically "succeeded" both times, silently corrupting the config file on the second run. Nothing about Ansible's output flags this as a problem; changed: true will show on every single run, which is itself a signal worth noticing (a task that's always changed, never ok, is very often not idempotent), but nothing stops you from missing that signal in a wall of playbook output.
The fix here is usually a purpose-built module instead of shell:
- name: Ensure max_connections is set correctly
ansible.builtin.lineinfile:
path: /etc/app/app.conf
regexp: '^max_connections='
line: 'max_connections=200'lineinfile checks whether the line already matches before deciding whether to change anything — genuinely idempotent, and it reports ok on the second run instead of changed. The broader lesson generalizes: prefer a purpose-built module over shell/command whenever one exists, precisely because idempotency is usually the module's problem to solve correctly, not yours.
Molecule: testing a role the way you'd test code
Molecule is the standard tool for testing Ansible roles — it spins up an isolated environment (commonly Docker containers, though other drivers exist), runs your role against it, and lets you assert on the result, all without touching any real infrastructure.
pip install molecule molecule-plugins[docker]
cd roles/nginx
molecule init scenario --driver-name dockerThis generates a molecule/default/ directory inside the role, containing its own inventory, its own test playbook, and a configuration describing what to spin up.
# roles/nginx/molecule/default/molecule.yml
---
driver:
name: docker
platforms:
- name: instance
image: geerlingguy/docker-ubuntu2204-ansible:latest
pre_build_image: true
provisioner:
name: ansible
verifier:
name: ansibleThe full test sequence, stage by stage
molecule test isn't a single check — it's an orchestrated sequence of distinct stages, each one catching a different category of problem:
molecule testmolecule test runs this full sequence automatically: create the container, run the role against it (converge), run the role a second time and fail the test if anything reports changed on that second run (idempotence), run any verification tests you've written (verify), and tear the container down (destroy). That idempotence stage is Molecule directly, automatically enforcing the property introduced earlier in this post — not something you have to remember to check by hand, and not something that depends on a human noticing a changed: true buried in a wall of output.
During active development, running the whole sequence on every small change is slow. Molecule exposes the individual stages so you can iterate faster:
molecule create # just spin up the container
molecule converge # apply the role — run this repeatedly while iterating
molecule verify # just run the verification tasks
molecule destroy # tear down when you're donemolecule converge specifically is the command most people actually run in a tight edit-test loop — apply the role, inspect the container directly if something looks wrong, fix the role, converge again — reserving the full molecule test (with its idempotence check) for before committing, or as the check that actually runs in CI.
Writing an actual assertion
A role that "ran without error" still hasn't verified it did the right thing. Molecule's verifier step is where you check that:
# roles/nginx/molecule/default/verify.yml
---
- name: Verify
hosts: all
tasks:
- name: Check nginx is running
ansible.builtin.command: systemctl is-active nginx
register: nginx_status
changed_when: false
failed_when: nginx_status.stdout != "active"
- name: Check the config file exists
ansible.builtin.stat:
path: /etc/nginx/nginx.conf
register: config_file
failed_when: not config_file.stat.exists
- name: Check nginx actually serves a request
ansible.builtin.uri:
url: http://localhost/
status_code: 200
register: responseThis is a genuinely different kind of confidence than "the playbook completed." It's an explicit assertion — nginx must actually be active, the config file must actually exist, a real HTTP request must actually succeed — that fails loudly and specifically if the role's behavior ever regresses, whether that regression comes from a change to the role itself or from a change to one of its dependencies (a new base image, an updated nginx package that changed a default).
Testing across multiple platforms
A role that only ever gets tested against one Ubuntu image can still break silently on RHEL, or on a different Ubuntu LTS version, the moment a real host running that platform applies it. Molecule scenarios support testing against several platforms in one run:
# roles/nginx/molecule/default/molecule.yml
---
driver:
name: docker
platforms:
- name: ubuntu2204
image: geerlingguy/docker-ubuntu2204-ansible:latest
- name: ubuntu2004
image: geerlingguy/docker-ubuntu2004-ansible:latest
- name: rockylinux9
image: geerlingguy/docker-rockylinux9-ansible:latest
provisioner:
name: ansible
verifier:
name: ansiblemolecule test then runs the entire create → converge → idempotence → verify → destroy sequence against each platform independently, surfacing exactly which platform (if any) a role's behavior diverges on. This matters directly for the ansible_facts['os_family'] conditional logic covered in the previous post in this series — a role branching on OS family is precisely the kind of role most likely to work perfectly on the platform a developer happens to test on locally, and fail in a way nobody notices until it runs against a different platform in production.
Multiple scenarios: testing more than the default configuration
A role often needs to behave correctly under more than one configuration — different variable values, different combinations of optional features enabled. Molecule supports named scenarios beyond default for exactly this:
molecule init scenario --scenario-name high-traffic --driver-name docker# roles/nginx/molecule/high-traffic/converge.yml
---
- name: Converge
hosts: all
vars:
nginx_worker_connections: 8192
nginx_client_max_body_size: "50m"
roles:
- nginxmolecule test --scenario-name high-trafficThis runs the full test sequence again, but with the high-traffic scenario's specific variable overrides — verifying the role behaves correctly not just with its defaults, but with the configuration a real high-traffic production host would actually use. A role with several genuinely distinct real-world configurations is worth a scenario per configuration, rather than trusting that testing the defaults alone generalizes to every way the role actually gets called.
Where this fits in a real workflow
The realistic adoption path for most teams isn't "test every role exhaustively from day one" — it's testing the roles that would cause the most damage if they silently broke: anything touching production database configuration, anything managing firewall or security group rules, anything that's been the source of a real incident before.
# .github/workflows/molecule.yml
name: Molecule Test
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
role: [nginx, postgresql, common]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install molecule molecule-plugins[docker] ansible
- run: molecule test
working-directory: roles/${{ matrix.role }}Wiring Molecule into CI on every pull request — using a build matrix to test every role in the repository, not just one — means a change to any role gets verified — idempotency and behavior both, across every platform the scenario defines — before it ever runs against real infrastructure. This is the same "quality gate before deploy" principle behind requiring GitHub Actions checks to pass before a merge, covered in our GitHub Actions series, applied specifically to configuration-management code.
What to actually remember from this series
- A task that's always
changed, neverok, is a strong signal it isn't idempotent — watch for it in your own playbook output, not just in Molecule's automated check. - Prefer purpose-built modules over
shell/commandwherever one exists — idempotency is usually the module's problem to solve, not yours. - Molecule's stage sequence — create, converge, idempotence, verify, destroy — automatically enforces idempotency, by running your role twice and failing if the second run reports any change.
- Write real verification tasks, not just "did it complete" — an explicit assertion (a service is active, a request actually succeeds) is what catches a genuine regression instead of a green checkmark that means less than it looks like.
- Test across the platforms and configurations a role actually needs to support — multiple platforms in one scenario, multiple named scenarios for genuinely different configurations — rather than trusting that testing the default configuration on one platform generalizes.
- Wire Molecule into CI with a matrix across roles, gating merges the same way any other automated test would, reserving exhaustive coverage for the roles that would cause the most damage if they silently broke.
That's the full Ansible fundamentals series — from ad-hoc commands through roles, variables and templating, Vault, and now testing. If your team is standardizing configuration management across a real fleet, this is exactly the kind of hands-on work we build our DevOps & Automation training around.
