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.
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: ansiblemolecule testmolecule test runs a full sequence automatically: create the container, run the role against it, run the role a second time and fail the test if anything reports changed on that second run, run any verification tests you've written, and tear the container down. That second run is Molecule directly, automatically enforcing the idempotency property — not something you have to remember to check by hand.
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.existsThis 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 — 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.
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
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/nginxWiring Molecule into CI on every pull request means a change to a role gets verified — idempotency and behavior both — before it ever runs against real infrastructure, the same principle behind testing any other piece of code before it ships.
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 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 is what catches a genuine regression instead of a green checkmark that means less than it looks like.
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.
