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.