Testing

Testing Ansible Roles with Molecule: The Complete Guide

Teach me Ansible | 2026-08-14 | 23 min read

Untested Ansible roles are technical debt waiting to happen. Learn how to build a complete testing pipeline for your roles — from ansible-lint through Molecule with Docker, idempotence checks, multi-distro matrices, and CI integration — so every change ships with confidence.

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:

  1. Lint (ansible-lint, yamllint) — seconds. Catches style problems, deprecated syntax, risky patterns, and outright bugs without executing anything.
  2. Syntax check (ansible-playbook --syntax-check) — seconds. Verifies the play parses and referenced files exist.
  3. Molecule — minutes. Actually runs your role against disposable containers or VMs, verifies the result, and confirms idempotence.
  4. 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
Tip: Start with 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/shell tasks — always report changed. Add creates:/removes: arguments, a when: guard based on a check task, or changed_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.
  • lineinfile regex 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
Security note: 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: true with the geerlingguy/docker-*-ansible images 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.yml for 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-python with cache: pip plus a registry-cached image cuts minutes per run.
  • Develop with converge, not test. Keep the instance alive and re-run molecule converge as you edit; only run the full molecule test before 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-lint on 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 converge during 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.