Testing Ansible Playbooks with Molecule
Ansible playbooks are often "tested" by running them against production and hoping. Molecule changes that — it provides a framework for testing roles and playbooks in isolated containers or VMs, verifying your automation actually works before it touches real infrastructure.
What Is Molecule?
Molecule is the official testing framework for Ansible roles and collections. It manages the full lifecycle:
- Create — spin up test instances (Docker, Vagrant, EC2, etc.)
- Prepare — run prerequisite tasks
- Converge — apply your role/playbook
- Verify — assert the desired state
- Destroy — clean up instances
You can run this cycle as many times as needed during development.
Installation
# Install Molecule with Docker driver (most common)
pip install molecule molecule-docker
<span class="hljs-comment"># Or with all drivers
pip install molecule molecule-docker molecule-vagrant ansible-lint
<span class="hljs-comment"># Verify installation
molecule --version
ansible-lint --versionInitializing Molecule in an Existing Role
# Navigate to your role directory
<span class="hljs-built_in">cd ansible/roles/nginx/
<span class="hljs-comment"># Add Molecule to existing role
molecule init scenario --driver-name docker
<span class="hljs-comment"># This creates:
<span class="hljs-comment"># molecule/
<span class="hljs-comment"># └── default/
<span class="hljs-comment"># ├── molecule.yml # Molecule configuration
<span class="hljs-comment"># ├── converge.yml # Playbook to apply your role
<span class="hljs-comment"># ├── verify.yml # Verification playbook
<span class="hljs-comment"># └── prepare.yml # Optional pre-converge setupMolecule Configuration
molecule/default/molecule.yml:
---
dependency:
name: galaxy
options:
requirements-file: requirements.yml
driver:
name: docker
platforms:
- name: instance-ubuntu
image: "geerlingguy/docker-ubuntu2204-ansible:latest"
pre_build_image: true
- name: instance-centos
image: "geerlingguy/docker-centos8-ansible:latest"
pre_build_image: true
provisioner:
name: ansible
config_options:
defaults:
interpreter_python: auto_silent
playbooks:
converge: converge.yml
verify: verify.yml
prepare: prepare.yml
inventory:
host_vars:
instance-ubuntu:
ansible_python_interpreter: /usr/bin/python3
instance-centos:
ansible_python_interpreter: /usr/bin/python3
verifier:
name: ansible
lint: |
set -e
yamllint .
ansible-lintTesting on multiple platforms (Ubuntu 22.04 and CentOS 8 in this example) ensures your role works across target distributions.
The Converge Playbook
molecule/default/converge.yml applies your role to test instances:
---
- name: Converge
hosts: all
become: true
vars:
nginx_worker_processes: 2
nginx_listen_port: 80
nginx_server_name: "test.example.com"
roles:
- role: nginxPass variables here that simulate realistic production configurations. Don't use defaults for everything — defaults hide misconfiguration bugs.
Writing Verifications
molecule/default/verify.yml checks the desired state:
---
- name: Verify
hosts: all
become: true
gather_facts: false
tasks:
- name: Check that nginx is installed
ansible.builtin.package:
name: nginx
state: present
check_mode: true
register: nginx_installed
failed_when: nginx_installed.changed
- name: Check nginx service is running
ansible.builtin.service:
name: nginx
state: started
enabled: true
check_mode: true
register: nginx_service
failed_when: nginx_service.changed
- name: Verify nginx responds on port 80
ansible.builtin.uri:
url: "http://localhost:80"
status_code: 200
register: response
- name: Assert nginx config has correct worker processes
ansible.builtin.shell: grep "worker_processes 2" /etc/nginx/nginx.conf
changed_when: false
- name: Check nginx configuration is valid
ansible.builtin.command: nginx -t
changed_when: false
register: nginx_config_test
failed_when: nginx_config_test.rc != 0These verifications confirm that the role didn't just run without errors — it actually achieved the desired state.
Running Molecule
# Full test sequence: create → converge → verify → destroy
molecule <span class="hljs-built_in">test
<span class="hljs-comment"># Step by step (useful for debugging)
molecule create
molecule converge
molecule verify
molecule destroy
<span class="hljs-comment"># Re-apply role without recreating instances (fast iteration)
molecule converge
<span class="hljs-comment"># Run verification only (after converge)
molecule verify
<span class="hljs-comment"># Log into an instance for debugging
molecule login --host instance-ubuntuDuring development, use molecule converge in a loop — it applies your role to existing instances without recreating them. Much faster than molecule test.
Idempotency Testing
A core Ansible requirement: running a playbook twice should produce the same result as running it once. Molecule tests this automatically:
# molecule/default/molecule.yml
provisioner:
name: ansible
options:
# Enable idempotency check — fails if second run makes changes
idempotency:
enabled: true
max_retries: 1When enabled, Molecule runs converge twice and fails if the second run reports any changed tasks. This catches non-idempotent role logic like:
# Bad — always "changes" even when nothing needs changing
- name: Append line to config
ansible.builtin.shell: echo "option=value" >> /etc/myapp/config
# Good — idempotent
- name: Ensure option in config
ansible.builtin.lineinfile:
path: /etc/myapp/config
line: "option=value"
create: trueLinting with ansible-lint
ansible-lint catches common Ansible anti-patterns before you even run Molecule:
# Lint a role
ansible-lint roles/nginx/
<span class="hljs-comment"># Lint a playbook
ansible-lint site.yml
<span class="hljs-comment"># With custom rules
ansible-lint --rulesdir custom_rules/Configure it in .ansible-lint:
---
warn_list:
- skip_this_tag
exclude_paths:
- .cache/
- molecule/
profile: production # strict ruleset
# Custom rule configuration
rules:
no-changed-when:
severity: error
command-instead-of-module:
severity: errorCommon rules it enforces:
- Use modules instead of shell commands when a module exists
- Always set
changed_whenforcommand/shelltasks - Use
name:on every task - Avoid
when: ansible_os_family == 'Debian'with deprecated syntax
Testing Variables and Edge Cases
Create multiple scenarios to test different configurations:
# Create a second scenario for a different configuration
molecule init scenario --scenario-name with-ssl --driver-name dockermolecule/
├── default/ # Default install, HTTP only
│ ├── molecule.yml
│ ├── converge.yml
│ └── verify.yml
└── with-ssl/ # HTTPS configuration
├── molecule.yml
├── converge.yml # Sets ssl_enabled: true
└── verify.yml # Verifies HTTPS worksRun a specific scenario:
molecule test --scenario-name with-sslUsing Vagrant Instead of Docker
For testing roles that require full VMs (kernel modules, systemd, reboot behavior):
pip install molecule-vagrant# molecule/default/molecule.yml
driver:
name: vagrant
platforms:
- name: instance
box: "bento/ubuntu-22.04"
memory: 2048
cpus: 2
provider_raw_config_args:
- "customize ['modifyvm', :id, '--natdnshostresolver1', 'on']"Vagrant is slower to start (60+ seconds vs Docker's 5 seconds) but provides a real VM environment needed for kernel-level testing.
CI Integration
GitHub Actions:
name: Ansible Role Tests
on:
push:
paths:
- 'ansible/roles/**'
pull_request:
paths:
- 'ansible/roles/**'
jobs:
molecule:
runs-on: ubuntu-latest
strategy:
matrix:
role:
- nginx
- postgresql
- redis
fail-fast: false
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install molecule molecule-docker ansible-lint yamllint
pip install ansible
- name: Run molecule tests
run: molecule test
working-directory: ansible/roles/${{ matrix.role }}
env:
PY_COLORS: '1'
ANSIBLE_FORCE_COLOR: '1'Preparing Test Instances
Some roles require prerequisites — other packages or configurations that aren't part of the role itself:
# molecule/default/prepare.yml
---
- name: Prepare
hosts: all
become: true
tasks:
- name: Install Python (if not present)
ansible.builtin.raw: |
test -e /usr/bin/python3 || apt-get install -y python3
changed_when: false
- name: Install curl for verification tests
ansible.builtin.package:
name: curl
state: presentEnd-to-End Testing After Deployment
Molecule verifies that your Ansible role configures infrastructure correctly. But it doesn't verify that the deployed service is working correctly in production — that the API is responding, that the database is accepting connections, or that the monitoring is set up.
HelpMeTest provides continuous end-to-end testing that runs after every deployment. Write scenarios in plain English, and HelpMeTest monitors your infrastructure's behavior 24/7, catching regressions that surface after Ansible applies changes.
Summary
molecule testruns the full lifecycle: create → converge → verify → destroy- Use Docker for fast iteration; use Vagrant for kernel/systemd-dependent roles
- Write real verifications in
verify.yml— don't just check that tasks ran - Enable idempotency testing to catch non-idempotent task implementations
- Run ansible-lint as the first check, before even starting Molecule
- Test multiple platforms in one scenario (Ubuntu + CentOS) to catch distribution differences
- Use multiple scenarios for different role configurations
Molecule turns "it worked on staging" from a prayer into a verified fact.