Ansible Deep Dive Part 8: Testing Ansible — Molecule, ansible-lint, and CI Pipelines
Everything up to here has assumed you’re the only one running these playbooks and you’ll notice if something’s wrong. That assumption doesn’t survive contact with a second contributor, or six months of not looking at a role. This post covers the three layers of testing that make Ansible code trustworthy enough to hand to a team, or to a CI pipeline that gates merges.
ansible-lint: catching problems before anything runs
pip install ansible-lint
ansible-lint site.yml
ansible-lint checks style and correctness against a defined rule set — deprecated syntax (with_items instead of loop), missing name: fields on tasks, command/shell used where a proper module exists, unquoted Jinja2 in conditionals, and dozens more. Nothing here requires infrastructure to run against; it’s pure static analysis over the YAML, and it runs in seconds.
# .ansible-lint
exclude_paths:
- .cache/
- molecule/
skip_list:
- yaml[line-length]
I keep a small skip list rather than fighting every rule to zero on day one of adopting it on an existing codebase — yaml[line-length] is the one I disable most often, since wrapping a long when: condition across lines usually hurts readability more than it helps.
Molecule: testing a role against a real, disposable target
Linting catches syntax problems. It can’t tell you whether a role actually produces a running nginx with the right config on a real Ubuntu box. That’s what Molecule is for — it spins up a disposable target (Docker container, Vagrant VM, or cloud instance), runs your role against it, asserts on the resulting state, then tears it down.
pip install molecule molecule-plugins[docker]
cd roles/nginx
molecule init scenario --driver-name docker
This scaffolds a molecule/default/ directory:
roles/nginx/molecule/default/
molecule.yml # driver config — what platform(s) to test against
converge.yml # the playbook that applies the role under test
verify.yml # assertions after convergence
# molecule/default/molecule.yml
driver:
name: docker
platforms:
- name: ubuntu-test
image: geerlingguy/docker-ubuntu2204-ansible
- name: rocky-test
image: geerlingguy/docker-rockylinux9-ansible
Testing against two platforms in the same run is exactly the point — a role that only ever gets exercised against Ubuntu will quietly accumulate Debian-family assumptions (apt, /etc/nginx/sites-available/) that break silently the first time someone applies it to a RHEL-family host.
# molecule/default/converge.yml
---
- name: Converge
hosts: all
become: true
roles:
- nginx
Verify with Testinfra
# molecule/default/tests/test_default.py
def test_nginx_is_installed(host):
pkg = host.package("nginx")
assert pkg.is_installed
def test_nginx_is_running(host):
svc = host.service("nginx")
assert svc.is_running
assert svc.is_enabled
def test_listens_on_80(host):
assert host.socket("tcp://0.0.0.0:80").is_listening
Testinfra assertions run against the actual converged container — genuinely checking a package is installed and a socket is listening, not just that the playbook reported success. This is the gap linting can never close: a role can be perfectly well-formed YAML and still not produce the state you intended.
molecule test # full lifecycle: create, converge, idempotence check, verify, destroy
molecule converge # just apply the role, leave the container running for debugging
The idempotence check is the one that catches the most real bugs
molecule test’s default sequence runs converge twice in a row and fails the whole test if the second run reports anything other than zero changes. This is Part 3’s idempotency contract, enforced automatically rather than trusted on faith — and in practice it’s the single check that catches the most real bugs: a command task without a proper creates: guard, a template that regenerates with different-but-equivalent whitespace on every run, a lineinfile pattern that matches too loosely and re-adds a line it should have detected as already present. All of these pass a single converge cleanly and only surface on the second pass.
Wiring it into CI
# .github/workflows/test.yml
name: Test Ansible role
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install ansible-lint
- run: ansible-lint
molecule:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install molecule molecule-plugins[docker] ansible
- run: molecule test
working-directory: roles/nginx
Two jobs, running in parallel on every pull request: fast static linting, and the slower but far more meaningful Molecule convergence-and-idempotence test. A broken role fails the PR check before it ever reaches a real host — which is the whole point of testing infrastructure code with the same rigor as application code, rather than finding out a role is broken the first time it’s run against something that matters.
Parts 9 and 10 are the two labs this whole series has been building toward — a full three-tier web application provisioned from bare Ubuntu boxes to a working stack, and a mixed Cisco IOS / FortiGate network fleet configured with the same rigor. Part 9 starts with the web app.