Testing automation: lint, check mode and Molecule

ansible-lint profiles and rules, --check and --diff with their limits, Molecule scenarios on containers, and running the whole thing in CI.

Linting the code

# .ansible-lint
profile: production        # or: min, basic, moderate, safety
exclude_paths:
  - .cache/
  - collections/
  - molecule/
warn_list:
  - experimental
skip_list:
  - jinja[spacing]         # only if you disagree, and say why in a comment
use_default_rules: true
offline: false
ansible-lint                      # human-readable
ansible-lint -f pep8              # machine-readable for CI
ansible-lint --fix                # applies what it can safely rewrite
ansible-lint roles/web/tasks/main.yml

# the rules that catch the most real problems
# no-changed-when        a command task without a change condition
# risky-shell-pipe       a shell pipeline without pipefail
# command-instead-of-module   using command where a module exists
# name[template]         a task name built from variables
  • The production profile is the strictest set of opt-in rules and is a reasonable target for shared roles.
  • Every shell task needs set -o pipefail, or a failing command earlier in the pipeline is masked by a later one that succeeds.
  • A task name containing a variable is fine for a loop with loop_control.label, but lint will ask you to be explicit.
  • Lint is not a substitute for running the play. It cannot tell you that a package name is wrong for the target distribution.

Check mode and diff

# a dry run: report what would change, change nothing
ansible-playbook -i inventory site.yml --check --diff --limit web1

# check mode with a tag filter is the fastest useful review
ansible-playbook -i inventory site.yml --check --diff --tags config

# syntax and inventory checks that need no connection at all
ansible-playbook site.yml --syntax-check
ansible-inventory -i inventory/hosts.ini --list
ansible-inventory -i inventory/hosts.ini --graph
Task typeCheck mode behaviourRisk
template, copy, fileReports the change accuratelyLow
packageUsually accurateLow
serviceReports, does not actLow
command / shellSkipped unless check_mode: falseHas side effects when forced
uri with a writeRuns unless you guard itReal data changes
⚠️
Check mode is only as truthful as the modules you use. A command task with check_mode: false genuinely executes during a dry run, and a task whose result a later when depends on will behave differently in check mode. Never treat a clean check run as proof a deploy is safe.

Molecule and CI

# molecule/default/molecule.yml
dependency:
  name: galaxy
driver:
  name: docker
platforms:
  - name: ubuntu-2404
    image: geerlingguy/docker-ubuntu2404-ansible:latest
    pre_build_image: true
    command: /usr/sbin/init
    privileged: true
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
  - name: debian-12
    image: geerlingguy/docker-debian12-ansible:latest
    pre_build_image: true
    command: /usr/sbin/init
    privileged: true
provisioner:
  name: ansible
  playbooks:
    converge: converge.yml
    verify: verify.yml
  inventory:
    group_vars:
      all:
        app_version: "2.4.0"
verifier:
  name: ansible
scenario:
  test_sequence:
    - dependency
    - destroy
    - create
    - converge
    - idempotence      # runs converge twice and fails if the second changes anything
    - verify
    - destroy
# molecule/default/verify.yml
- name: Verify
  hosts: all
  become: true
  tasks:
    - name: The service is listening
      ansible.builtin.wait_for:
        port: 8080
        timeout: 30

    - name: The health endpoint answers
      ansible.builtin.uri:
        url: http://127.0.0.1:8080/health
        status_code: 200

    - name: The config file has the expected owner and mode
      ansible.builtin.stat:
        path: /etc/app/app.env
      register: env
      failed_when: env.stat.mode != '0640' or env.stat.pw_name != 'app'
# .github/workflows/ansible.yml
name: ansible
on: [push, pull_request]
jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.12' }
      - run: pip install ansible-core==2.17.* ansible-lint molecule molecule-plugins[docker]
      - run: ansible-galaxy collection install -r requirements.yml
      - run: ansible-lint
      - run: molecule test
        working-directory: roles/web

The idempotence step is the most valuable part of a Molecule run: converging twice and failing on any change on the second pass is the only automated proof that your tasks converge instead of repeating themselves.

FAQ

Is Molecule worth it for a small role?
Yes for any role you will run more than once on more than one distribution. The idempotence check alone catches the mistakes that cause an unexpected restart in production.
Why does my task pass in check mode but fail for real?
A command or shell task is skipped in check mode, so a dependency it creates is never created, and the real run takes a different path. Check mode cannot validate tasks that shell out.

Error handling, blocks and rescue Scaling: dynamic inventory and AWX

Last refreshed 2026-09-18.