Ansible Molecule with Docker: Testing Roles Properly From Scratch
Most Ansible roles are never tested. You write the role, run it on a server, see that it "works," and commit. The next engineer who touches it runs into a failure on Ubuntu 22 that worked on Ubuntu 20, or discovers the role isn't idempotent — running it twice leaves the service in a broken state.
Molecule is the standard testing framework for Ansible roles. It creates containers (or VMs), runs your role against them, and verifies the result. This guide covers the Docker driver specifically — the fastest approach for local development and CI.
What Molecule Tests
- Syntax — does the role have valid YAML and Ansible syntax?
- Execution — does the role run without errors?
- Idempotency — does running the role twice produce no changes on the second run?
- Verification — does the role produce the expected state (service running, config file correct)?
- Multi-OS — does the role work on Ubuntu 20, Ubuntu 22, RHEL 8?
Installation
pip install molecule molecule-plugins[docker] ansible-lintYou also need Docker installed and running.
Creating a Molecule Scenario
Initialize Molecule in an existing role:
cd roles/nginx
molecule init scenario --driver-name dockerThis creates:
roles/nginx/
molecule/
default/
molecule.yml # Scenario configuration
converge.yml # Playbook that applies the role
verify.yml # Verification tests
prepare.yml # Optional: pre-role setupmolecule.yml: Configuring the Scenario
# molecule/default/molecule.yml
---
dependency:
name: galaxy
driver:
name: docker
platforms:
- name: instance-ubuntu22
image: "geerlingguy/docker-ubuntu2204-ansible:latest"
pre_build_image: true
privileged: true
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:ro
command: /lib/systemd/systemd # Enable systemd in container
provisioner:
name: ansible
config_options:
defaults:
interpreter_python: auto_silent
verifier:
name: ansible
lint: |
set -e
ansible-lintconverge.yml: Applying the Role
# molecule/default/converge.yml
---
- name: Converge
hosts: all
become: true
vars:
nginx_user: www-data
nginx_worker_processes: auto
nginx_server_names_hash_bucket_size: 128
roles:
- role: nginxverify.yml: Testing the Result
The verifier playbook checks that the role produced the correct state:
# molecule/default/verify.yml
---
- name: Verify
hosts: all
become: true
gather_facts: false
tasks:
- name: Check nginx is installed
ansible.builtin.command: nginx -v
register: nginx_version
changed_when: false
failed_when: nginx_version.rc != 0
- name: Check nginx service is running
ansible.builtin.service_facts:
- name: Assert nginx service is active
ansible.builtin.assert:
that: ansible_facts.services['nginx.service'].state == 'running'
fail_msg: "nginx service is not running"
- name: Check nginx config is valid
ansible.builtin.command: nginx -t
changed_when: false
register: config_test
failed_when: config_test.rc != 0
- name: Check nginx port 80 is listening
ansible.builtin.wait_for:
port: 80
timeout: 10
- name: Verify nginx responds to HTTP
ansible.builtin.uri:
url: "http://localhost"
return_content: yes
register: http_response
- name: Assert HTTP response is 200 or 404 (nginx running, no site configured)
ansible.builtin.assert:
that: http_response.status in [200, 404]
fail_msg: "nginx not responding correctly (got {{ http_response.status }})"
- name: Check nginx config file exists
ansible.builtin.stat:
path: /etc/nginx/nginx.conf
register: nginx_conf
- name: Assert nginx config file exists
ansible.builtin.assert:
that: nginx_conf.stat.exists
fail_msg: "/etc/nginx/nginx.conf does not exist"Running Molecule
# Run the full test sequence
molecule test
# Or run individual phases:
molecule create # Create containers
molecule converge # Apply the role
molecule verify # Run verification tests
molecule idempotency # Re-run role, check for changes
molecule destroy # Destroy containers
# Run without destroying (for debugging)
molecule converge && molecule verify
molecule login # SSH into the container for debuggingTesting Idempotency Explicitly
Molecule runs idempotency checks automatically in molecule test. You can also verify it in the verify playbook:
# In verify.yml
- name: Check that re-running role makes no changes (idempotency)
ansible.builtin.include_role:
name: nginx
register: role_result
- name: Assert no changes on second run
ansible.builtin.assert:
that: not role_result.changed
fail_msg: "Role is not idempotent — second run made changes"The built-in Molecule idempotency check is simpler: it reruns the converge playbook and fails if any tasks report changed.
Multi-OS Scenarios
Test your role against multiple operating systems:
# molecule/default/molecule.yml
platforms:
- name: ubuntu-20
image: "geerlingguy/docker-ubuntu2004-ansible:latest"
pre_build_image: true
privileged: true
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:ro
command: /lib/systemd/systemd
- name: ubuntu-22
image: "geerlingguy/docker-ubuntu2204-ansible:latest"
pre_build_image: true
privileged: true
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:ro
command: /lib/systemd/systemd
- name: rockylinux-9
image: "geerlingguy/docker-rockylinux9-ansible:latest"
pre_build_image: true
privileged: true
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:ro
command: /lib/systemd/systemdNow molecule test runs against all three simultaneously.
Multiple Scenarios
For complex roles, create multiple scenarios for different configurations:
molecule init scenario --scenario-name minimal --driver-name docker
molecule init scenario --scenario-name with-ssl --driver-name docker
molecule init scenario --scenario-name clustered --driver-name dockermolecule/
default/ # Standard installation
minimal/ # Minimal config, no extra modules
with-ssl/ # With SSL cert configuration
clustered/ # Multiple nodesRun a specific scenario:
molecule test --scenario-name with-sslprepare.yml: Pre-Role Setup
Some roles have prerequisites. Use prepare.yml to set them up:
# molecule/default/prepare.yml
---
- name: Prepare
hosts: all
become: true
tasks:
- name: Update apt cache (Ubuntu)
ansible.builtin.apt:
update_cache: yes
cache_valid_time: 3600
when: ansible_os_family == "Debian"
- name: Install dependencies
ansible.builtin.package:
name:
- curl
- ca-certificates
state: presentCI Integration
# .github/workflows/molecule.yml
name: Molecule Tests
on:
push:
paths: ["roles/**"]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
scenario: [default, with-ssl, minimal]
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- name: Install Molecule and dependencies
run: pip install molecule molecule-plugins[docker] ansible ansible-lint
- name: Run Molecule tests
run: molecule test --scenario-name ${{ matrix.scenario }}
working-directory: roles/nginx
env:
PY_COLORS: "1"
ANSIBLE_FORCE_COLOR: "1"Testing with ansible-lint
Add ansible-lint to your Molecule workflow to catch style issues and anti-patterns:
# molecule/default/molecule.yml
lint: |
set -e
ansible-lint roles/nginxOr run it separately:
ansible-lint roles/nginx/Common issues ansible-lint catches:
commandused instead of appropriate module- Missing
nameon tasks become: trueat task level when it should be at play level- Galaxy meta missing
Debugging Failed Tests
When a test fails, don't destroy the container immediately:
# Run without destroying
molecule converge
# If converge succeeds but verify fails:
molecule verify
# SSH into the container to debug
molecule login --host instance-ubuntu22
# Inside the container:
systemctl status nginx
journalctl -u nginx -n 50
nginx -tConclusion
Molecule with Docker gives you a complete testing workflow for Ansible roles: create containers, apply the role, verify state, check idempotency, then destroy. The Docker driver is fast enough for local development (30-60 seconds per scenario) and works in GitHub Actions without infrastructure access. Add multi-platform coverage with the matrix strategy and you'll know immediately when a change breaks Ubuntu 22 while passing Ubuntu 20.