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
- Test on multiple platforms — at minimum Ubuntu LTS + RHEL/Rocky
- Always verify — don't just converge, assert the result
- Test idempotence — roles should be safe to run twice
- Use
pre_build_image: true— faster than building images from scratch - Pin image versions — reproducible tests
- Run in CI — every push, every PR
- Test edge cases — create scenarios for upgrades, uninstall, different variables
- Keep tests fast — parallelize platforms, use lightweight assertions
Links
Related Articles
- Ansible Roles Guide
- Ansible Collections Guide
- Ansible-Lint Best Practices
- Ansible CI/CD with GitHub Actions
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.