Introduction

Molecule is the standard testing framework for Ansible roles and collections. It creates ephemeral instances (Docker containers, Podman, VMs), runs your role against them, verifies the result, and destroys the instances — all in one command. This ensures your roles work correctly before they reach production. This guide covers everything from basic setup to multi-platform testing and CI/CD integration.

Install Molecule

# Molecule with Docker driver (most common)
pip install molecule molecule-plugins[docker]

# Molecule with Podman driver
pip install molecule molecule-plugins[podman]

# Verify
molecule --version

Quick Start

Initialize a New Role with Molecule

# Create new role with Molecule scenario
ansible-galaxy role init my_role
cd my_role
molecule init scenario --driver-name docker

# Or add Molecule to existing role
cd existing_role
molecule init scenario --driver-name docker

Directory Structure

my_role/
├── defaults/
│   └── main.yml
├── handlers/
│   └── main.yml
├── meta/
│   └── main.yml
├── molecule/
│   └── default/
│       ├── converge.yml      # Playbook that runs the role
│       ├── molecule.yml      # Configuration
│       ├── prepare.yml       # Pre-test setup (optional)
│       ├── verify.yml        # Post-test assertions
│       └── cleanup.yml       # Teardown (optional)
├── tasks/
│   └── main.yml
├── templates/
├── tests/
└── vars/

Configuration: molecule.yml

Docker Driver (Default)

# molecule/default/molecule.yml
---
driver:
  name: docker

platforms:
  - name: ubuntu2404
    image: geerlingguy/docker-ubuntu2404-ansible:latest
    pre_build_image: true
    command: ""
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
    cgroupns_mode: host
    privileged: true

  - name: rhel9
    image: geerlingguy/docker-rockylinux9-ansible:latest
    pre_build_image: true
    command: ""
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
    cgroupns_mode: host
    privileged: true

provisioner:
  name: ansible
  inventory:
    host_vars:
      ubuntu2404:
        ansible_python_interpreter: /usr/bin/python3
      rhel9:
        ansible_python_interpreter: /usr/bin/python3
  playbooks:
    converge: converge.yml
    verify: verify.yml

verifier:
  name: ansible

lint: |
  set -e
  ansible-lint

Podman Driver

driver:
  name: podman

platforms:
  - name: ubuntu2404
    image: docker.io/geerlingguy/docker-ubuntu2404-ansible:latest
    pre_build_image: true
    systemd: true
    command: ""

Test Lifecycle

Molecule runs tests in this order:

dependency → cleanup → destroy → create → prepare → converge → 
idempotence → side_effect → verify → cleanup → destroy

Key Commands

# Full test lifecycle (recommended for CI)
molecule test

# Just converge (run the role) — useful during development
molecule converge

# Verify (run assertions)
molecule verify

# Check idempotence (run twice, fail if changes)
molecule idempotence

# Login to instance for debugging
molecule login --host ubuntu2404

# Destroy instances
molecule destroy

# List instances
molecule list

# Run specific scenario
molecule test --scenario-name custom

The Converge Playbook

# molecule/default/converge.yml
---
- name: Converge
  hosts: all
  become: true
  vars:
    nginx_port: 8080
    nginx_server_name: test.example.com
  roles:
    - role: my_nginx_role

With Pre/Post Tasks

---
- name: Converge
  hosts: all
  become: true
  pre_tasks:
    - name: Update apt cache (Debian)
      ansible.builtin.apt:
        update_cache: true
      when: ansible_os_family == 'Debian'

  roles:
    - role: my_nginx_role

  post_tasks:
    - name: Verify nginx is responding
      ansible.builtin.uri:
        url: http://localhost:8080
        status_code: 200

Verification

Ansible Verifier (Default)

# molecule/default/verify.yml
---
- name: Verify
  hosts: all
  become: true
  tasks:
    - name: Gather package facts
      ansible.builtin.package_facts:

    - name: Assert nginx is installed
      ansible.builtin.assert:
        that:
          - "'nginx' in ansible_facts.packages"
        fail_msg: "nginx is not installed"

    - name: Check nginx service is running
      ansible.builtin.service_facts:

    - name: Assert nginx 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 is listening on port 8080
      ansible.builtin.wait_for:
        port: 8080
        timeout: 10

    - name: Verify nginx config file
      ansible.builtin.stat:
        path: /etc/nginx/nginx.conf
      register: nginx_conf

    - name: Assert config exists with correct permissions
      ansible.builtin.assert:
        that:
          - nginx_conf.stat.exists
          - nginx_conf.stat.mode == '0644'

    - name: Test HTTP response
      ansible.builtin.uri:
        url: http://localhost:8080
        return_content: true
      register: response

    - name: Assert response contains expected content
      ansible.builtin.assert:
        that:
          - response.status == 200
          - "'Welcome' in response.content"

Testinfra Verifier (Python Tests)

# molecule.yml
verifier:
  name: testinfra
# molecule/default/tests/test_default.py
import pytest

def test_nginx_installed(host):
    nginx = host.package("nginx")
    assert nginx.is_installed

def test_nginx_running(host):
    nginx = host.service("nginx")
    assert nginx.is_running
    assert nginx.is_enabled

def test_nginx_listening(host):
    socket = host.socket("tcp://0.0.0.0:8080")
    assert socket.is_listening

def test_nginx_config(host):
    config = host.file("/etc/nginx/nginx.conf")
    assert config.exists
    assert config.user == "root"
    assert config.mode == 0o644

def test_nginx_response(host):
    cmd = host.run("curl -s http://localhost:8080")
    assert cmd.rc == 0
    assert "Welcome" in cmd.stdout

Prepare Playbook (Pre-test Setup)

# molecule/default/prepare.yml
---
- name: Prepare
  hosts: all
  become: true
  tasks:
    - name: Install prerequisites
      ansible.builtin.package:
        name:
          - curl
          - ca-certificates
        state: present

    - name: Create test user
      ansible.builtin.user:
        name: testuser
        state: present

Multiple Scenarios

molecule/
├── default/           # Basic functional test
│   ├── molecule.yml
│   ├── converge.yml
│   └── verify.yml
├── upgrade/           # Test upgrade path
│   ├── molecule.yml
│   ├── converge.yml
│   └── verify.yml
└── security/          # Test with hardened settings
    ├── molecule.yml
    ├── converge.yml
    └── verify.yml
# Run specific scenario
molecule test --scenario-name upgrade
molecule test --scenario-name security

# Run all scenarios
molecule test --all

Testing Collections

Collection Structure

my_namespace/my_collection/
├── extensions/
│   └── molecule/
│       └── default/
│           ├── molecule.yml
│           ├── converge.yml
│           └── verify.yml
├── roles/
│   └── webserver/
│       ├── molecule/
│       │   └── default/
│       │       ├── molecule.yml
│       │       ├── converge.yml
│       │       └── verify.yml
│       └── tasks/
│           └── main.yml
└── galaxy.yml

Collection molecule.yml

# extensions/molecule/default/molecule.yml
---
platforms:
  - name: instance
    image: geerlingguy/docker-ubuntu2404-ansible:latest
    pre_build_image: true

provisioner:
  name: ansible
  config_options:
    defaults:
      collections_path: ${ANSIBLE_COLLECTIONS_PATH}

Collection converge.yml

# extensions/molecule/default/converge.yml
---
- name: Test collection roles
  hosts: all
  become: true
  tasks:
    - name: Include webserver role
      ansible.builtin.include_role:
        name: my_namespace.my_collection.webserver
# Run from extensions directory
cd extensions
ANSIBLE_COLLECTIONS_PATH=../../.. molecule test

CI/CD Integration

GitHub Actions

# .github/workflows/molecule.yml
name: Molecule Test
on: [push, pull_request]

jobs:
  molecule:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        scenario:
          - default
          - upgrade
        distro:
          - ubuntu2404
          - rockylinux9
      fail-fast: false

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: |
          pip install molecule molecule-plugins[docker] ansible-lint

      - name: Run Molecule
        run: molecule test --scenario-name ${{ matrix.scenario }}
        env:
          MOLECULE_DISTRO: ${{ matrix.distro }}

GitLab CI

molecule:
  image: python:3.12
  services:
    - docker:dind
  variables:
    DOCKER_HOST: tcp://docker:2375
  before_script:
    - pip install molecule molecule-plugins[docker]
  script:
    - molecule test

Idempotence Testing

Molecule's idempotence step runs the converge playbook twice and fails if any task reports changed on the second run:

# Run idempotence check
molecule idempotence

To skip tasks in idempotence check:

- name: Task that legitimately changes every run
  ansible.builtin.command: date
  changed_when: false   # Mark as never changed

Debugging

# Login to running instance
molecule login --host ubuntu2404

# Run converge with verbose output
molecule --debug converge

# Keep instances after failure
molecule test --destroy=never

# Inspect instance
molecule login

Best Practices

  1. Test on multiple platforms — at minimum Ubuntu LTS + RHEL/Rocky
  2. Always verify — don't just converge, assert the result
  3. Test idempotence — roles should be safe to run twice
  4. Use pre_build_image: true — faster than building images from scratch
  5. Pin image versions — reproducible tests
  6. Run in CI — every push, every PR
  7. Test edge cases — create scenarios for upgrades, uninstall, different variables
  8. Keep tests fast — parallelize platforms, use lightweight assertions

Conclusion

Molecule automates the full test lifecycle for Ansible roles — create ephemeral instances, converge your role, verify the result, check idempotence, and destroy. Test against multiple platforms (Ubuntu, RHEL, Debian) in parallel, use the Ansible verifier or Testinfra for assertions, and integrate into GitHub Actions or GitLab CI for automated testing on every push. Every role you ship should have a Molecule scenario.