Testing Ansible Roles with Molecule: The Complete Guide
Why Untested Roles Rot
Every Ansible role starts life working perfectly — on the machine it was written against, on the day it was written. Then reality sets in. A new OS release ships with a renamed package. A module gets deprecated and its parameters change. A colleague "quickly fixes" a task and silently breaks idempotence. Six months later you run the role against a fresh server and it explodes, and nobody knows which of the forty commits since it last worked is to blame.
This is role rot, and it happens because Ansible code feels declarative and safe, so teams skip the testing discipline they'd apply to application code. But roles are code: they have logic, branching, templating, and external dependencies. Without automated tests, the only test environment is production, and the only test schedule is "whenever someone happens to run it."
The fix is the same as for any codebase: a fast, layered test pipeline that runs on every change. For Ansible, that pipeline has a well-established shape.
The Ansible Testing Pyramid
Like application testing, Ansible testing is layered from cheap-and-fast at the bottom to expensive-and-thorough at the top:
- Lint (
ansible-lint,yamllint) — seconds. Catches style problems, deprecated syntax, risky patterns, and outright bugs without executing anything. - Syntax check (
ansible-playbook --syntax-check) — seconds. Verifies the play parses and referenced files exist. - Molecule — minutes. Actually runs your role against disposable containers or VMs, verifies the result, and confirms idempotence.
- Integration/staging — runs the full playbook against real (staging) infrastructure with real inventories, secrets, and network dependencies.
Run the bottom layers on every commit and the top layer before release. Most bugs die at the lint and Molecule layers, which is exactly where you want them: fast feedback, no real infrastructure harmed.
Layer 1: ansible-lint
Install it alongside Ansible and run it at the repo root:
# Install
pip install ansible-lint
# Run against the whole project
ansible-lint
# Run against a single role
ansible-lint roles/nginx/
Configure it with a .ansible-lint file. The profile setting picks a strictness tier — min, basic, moderate, safety, shared, or production — and you can skip or warn on individual rules while you work toward full compliance:
# .ansible-lint
---
profile: production # strictest tier; use 'moderate' to start
exclude_paths:
- .cache/
- .github/
- molecule/
skip_list:
- galaxy[no-changelog] # not publishing to Galaxy
warn_list: # report but don't fail (yet)
- experimental
- fqcn[action-core]
enable_list:
- no-log-password # never leak secrets to output
- no-same-owner
profile: moderate and a generous warn_list, then ratchet toward production. Turning on strict linting all at once on a legacy repo produces hundreds of findings and everyone ignores them.
Layer 3: Molecule
Molecule is the standard framework for testing Ansible roles. It spins up disposable instances (Docker containers, Podman containers, VMs, cloud instances), applies your role to them, verifies the outcome, checks idempotence, and destroys everything afterwards. One command — molecule test — runs the full lifecycle.
Installation and Init
# Install molecule with the docker plugin
pip install molecule molecule-plugins[docker]
# Inside an existing role, create a default scenario
cd roles/nginx
molecule init scenario default --driver-name docker
Directory Anatomy
Molecule lives in a molecule/ directory inside your role. Each subdirectory is a scenario — an independent test configuration:
roles/nginx/
├── defaults/
├── handlers/
├── tasks/
├── templates/
└── molecule/
└── default/
├── molecule.yml # driver, platforms, provisioner config
├── converge.yml # playbook that applies the role
├── verify.yml # assertions about the end state
└── prepare.yml # (optional) pre-role setup
The default molecule test sequence is: dependency → cleanup → destroy → syntax → create → prepare → converge → idempotence → verify → cleanup → destroy. During development you'll mostly use the individual steps:
molecule create # spin up test instances
molecule converge # apply the role (re-run as you edit)
molecule verify # run assertions
molecule login # shell into the instance to poke around
molecule test # full lifecycle, as CI runs it
molecule destroy # tear down
Worked Example: Testing an Nginx Role with Docker
Here's a complete, realistic setup for a role that installs and configures nginx.
molecule/default/molecule.yml
---
driver:
name: docker
platforms:
- name: nginx-ubuntu2204
image: geerlingguy/docker-ubuntu2204-ansible:latest
pre_build_image: true
command: ""
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
privileged: true
provisioner:
name: ansible
config_options:
defaults:
callbacks_enabled: profile_tasks
inventory:
host_vars:
nginx-ubuntu2204:
nginx_listen_port: 8080
verifier:
name: ansible
molecule/default/converge.yml
The converge playbook simply applies the role the way a consumer would:
---
- name: Converge
hosts: all
become: true
pre_tasks:
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
when: ansible_os_family == "Debian"
roles:
- role: nginx
vars:
nginx_worker_processes: "{{ ansible_processor_vcpus | default(2) }}"
molecule/default/verify.yml
The verify playbook asserts the role actually did its job — package present, service running, port answering:
---
- name: Verify
hosts: all
become: true
gather_facts: true
tasks:
- name: Gather package facts
ansible.builtin.package_facts:
- name: Assert nginx package is installed
ansible.builtin.assert:
that: "'nginx' in ansible_facts.packages"
fail_msg: "nginx package is not installed"
- name: Gather service facts
ansible.builtin.service_facts:
- name: Assert nginx service is running and enabled
ansible.builtin.assert:
that:
- ansible_facts.services['nginx.service'].state == 'running'
- ansible_facts.services['nginx.service'].status == 'enabled'
- name: Check nginx responds on the configured port
ansible.builtin.uri:
url: "http://localhost:{{ nginx_listen_port | default(80) }}"
status_code: 200
register: web_response
retries: 3
delay: 2
until: web_response.status == 200
Run the whole thing:
molecule test
# ...
# PLAY RECAP: converge ok=8 changed=6
# IDEMPOTENCE: ok=8 changed=0 <-- the money line
# VERIFY: ok=6 failed=0
Idempotence Testing: The Underrated Killer Feature
Molecule's idempotence step runs your role a second time and fails the test if anything reports changed. This is one of the most valuable checks in the entire pipeline, because non-idempotent roles are the ones that page you at 3 AM: every scheduled run restarts a service, rewrites a file, or re-triggers handlers for no reason.
Common idempotence offenders and their fixes:
command/shelltasks — always report changed. Addcreates:/removes:arguments, awhen:guard based on a check task, orchanged_when:with a real condition.- Templates with timestamps or random values — the file differs on every render. Remove volatile content from templates.
- Unsorted lists in templates — dict iteration order changes between runs. Pipe through
| sort. lineinfileregex mismatches — the regex doesn't match the line it inserted, so it inserts again. Test the regex against the inserted line.
If a task legitimately must run every time, mark it honestly:
- name: Refresh application cache
ansible.builtin.command: /usr/local/bin/refresh-cache
register: refresh_result
changed_when: "'updated' in refresh_result.stdout"
Verifier Choice: Ansible vs Testinfra
Molecule supports two main verifiers. The ansible verifier (the default since Molecule 3) is what you saw above: verify.yml is just a playbook full of assert, uri, package_facts, and friends. No extra language, no extra dependencies, and your whole team can already read it.
The testinfra verifier uses pytest, which appeals to teams with a Python testing culture — you get fixtures, parametrization, and pytest's excellent failure output:
# molecule/default/tests/test_nginx.py
def test_nginx_installed(host):
pkg = host.package("nginx")
assert pkg.is_installed
def test_nginx_running(host):
svc = host.service("nginx")
assert svc.is_running
assert svc.is_enabled
def test_nginx_listening(host):
assert host.socket("tcp://0.0.0.0:8080").is_listening
def test_config_valid(host):
cmd = host.run("nginx -t")
assert cmd.rc == 0
Enable it in molecule.yml with verifier: {name: testinfra} and pip install pytest-testinfra.
Recommendation: default to the ansible verifier. It keeps the toolchain to one language and one skill set. Reach for testinfra when your assertions get genuinely complex (parsing command output, parametrizing across dozens of cases) or your team already lives in pytest.
Testing Across Multiple Distros
A role that claims to support Ubuntu and RHEL should prove it. The platforms list in molecule.yml is a matrix — every platform goes through converge, idempotence, and verify:
platforms:
- name: nginx-ubuntu2204
image: geerlingguy/docker-ubuntu2204-ansible:latest
pre_build_image: true
privileged: true
cgroupns_mode: host
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
- name: nginx-ubuntu2404
image: geerlingguy/docker-ubuntu2404-ansible:latest
pre_build_image: true
privileged: true
cgroupns_mode: host
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
- name: nginx-rockylinux9
image: geerlingguy/docker-rockylinux9-ansible:latest
pre_build_image: true
privileged: true
cgroupns_mode: host
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
- name: nginx-debian12
image: geerlingguy/docker-debian12-ansible:latest
pre_build_image: true
privileged: true
cgroupns_mode: host
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
The geerlingguy/docker-*-ansible images are the community standard for this: they ship with Python, systemd, and sudo pre-installed, so your role tests OS logic instead of fighting container bootstrap problems. In CI, it's often better to parameterize a single platform via an environment variable and let the CI matrix fan out (next section) — you get parallelism and per-distro pass/fail visibility.
Molecule in CI: GitHub Actions
Here's a workflow that lints once, then runs Molecule across a distro matrix in parallel:
# .github/workflows/molecule.yml
---
name: Molecule CI
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install lint tools
run: pip install ansible-lint yamllint
- name: Run ansible-lint
run: ansible-lint
molecule:
needs: lint
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
distro: [ubuntu2204, ubuntu2404, rockylinux9, debian12]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install test dependencies
run: pip install ansible molecule molecule-plugins[docker]
- name: Run Molecule
run: molecule test
env:
MOLECULE_DISTRO: ${{ matrix.distro }}
PY_COLORS: "1"
ANSIBLE_FORCE_COLOR: "1"
To make the matrix work, reference the variable in molecule.yml:
platforms:
- name: instance
image: "geerlingguy/docker-${MOLECULE_DISTRO:-ubuntu2204}-ansible:latest"
pre_build_image: true
privileged: true
cgroupns_mode: host
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
Testing Roles That Need systemd
Plain containers don't run an init system, so any role that manages services with systemd will fail with "System has not been booted with systemd." You have two good options:
Option 1: Privileged Docker Containers
Run a systemd-enabled image as PID 1 with cgroup access — this is what the platform snippets above do:
platforms:
- name: instance
image: geerlingguy/docker-ubuntu2204-ansible:latest
pre_build_image: true
command: "" # let the image's systemd init run
privileged: true # required for systemd in Docker
cgroupns_mode: host # needed on cgroup v2 hosts
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
Option 2: Podman (Cleaner)
Podman has first-class systemd support and doesn't need privileged in most cases — it's an easy switch and increasingly the better default, especially on RHEL-family CI runners:
pip install molecule-plugins[podman]
driver:
name: podman
platforms:
- name: instance
image: geerlingguy/docker-rockylinux9-ansible:latest
pre_build_image: true
systemd: always # podman handles the systemd plumbing
command: /usr/sbin/init
privileged: true gives the container broad access to the host. That's acceptable for throwaway CI runners, but avoid it on shared or long-lived machines — prefer Podman there.
Speed Tips
Slow test suites don't get run. Keep Molecule fast:
- Use pre-built images.
pre_build_image: truewith thegeerlingguy/docker-*-ansibleimages skips Molecule's Dockerfile build step entirely — often the single biggest saving. - Bake dependencies into a custom image. If your role always installs the same base packages, build an image with them pre-installed and test only your role's real work on top.
- Use
prepare.ymlfor one-time setup. Prepare runs once per instance creation, not on every converge — put apt cache updates and repo setup there. - Cache pip and container layers in CI.
actions/setup-pythonwithcache: pipplus a registry-cached image cuts minutes per run. - Develop with
converge, nottest. Keep the instance alive and re-runmolecule convergeas you edit; only run the fullmolecule testbefore pushing. - Parallelize distros in the CI matrix instead of listing them all in one scenario — wall-clock time stays flat as coverage grows.
When Molecule Is Overkill
Not everything deserves a container matrix. Skip Molecule (and rely on lint + syntax check + a staging run) when:
- The role only manipulates cloud APIs — creating AWS resources or DNS records has nothing to verify inside a container; test against a sandbox account instead.
- It's a trivial config-drop role — one template and a handler barely has behavior to test; ansible-lint plus a check-mode run covers it.
- The role targets network devices or appliances — you can't run IOS in a Docker container; use vendor labs, containerlab, or check mode against real devices.
- It's a one-off migration playbook that will run exactly once and be deleted.
The judgment call: Molecule pays off in proportion to how often a role changes, how many platforms it supports, and how bad a silent failure would be. A base-hardening role used on every server? Absolutely test it. A playbook that toggles one feature flag? Lint it and move on.
Conclusion
A tested role is a role you can refactor, upgrade, and hand to a teammate without fear. The recipe is straightforward:
- Lint everything with
ansible-linton a strict profile - Give every non-trivial role a Molecule scenario with converge + verify
- Let the idempotence step guard your 3 AM sanity
- Prove multi-distro claims with a platform matrix in CI
- Keep it fast with pre-built images and
molecule convergeduring development
Start small: pick your most-changed role, run molecule init scenario, and write three assertions in verify.yml. Once the first red-to-green cycle catches a real bug — and it will — the rest of your roles won't stay untested for long.
Pro Tip
Add molecule test as a required status check on your role repositories' pull requests. Reviews get faster because reviewers stop manually reasoning about "will this still converge?" — CI already answered.