Ansible Molecule for Role Testing
Ansible Molecule provides a testing framework for Ansible roles that automates the full lifecycle of creating test instances, running roles against them, verifying the results, and tearing down the environment. By integrating Molecule with Docker and Testinfra into your CI/CD pipeline, you can ensure Ansible roles behave correctly across multiple platforms before deployment.
Prerequisites
- Python 3.8+ with pip
- Docker Engine installed and running
- Ansible 6.x+
- An existing Ansible role (or you'll create one during testing)
# Verify prerequisites
python3 --version
docker --version
ansible --version
# Docker must be running
docker ps
Installing Molecule
# Create a virtual environment (recommended)
python3 -m venv molecule-venv
source molecule-venv/bin/activate
# Install Molecule with Docker driver
pip install molecule molecule-docker
# Install testing dependencies
pip install pytest testinfra ansible-lint yamllint
# Verify installation
molecule --version
ansible-lint --version
# For managing Python deps, use a requirements file
cat > requirements-dev.txt <<EOF
ansible>=6.0
molecule>=6.0
molecule-docker>=2.0
pytest>=7.0
pytest-testinfra>=9.0
ansible-lint>=6.0
yamllint>=1.28
EOF
pip install -r requirements-dev.txt
Creating a Test Scenario
# Initialize Molecule in an existing role
cd /path/to/your/ansible/roles/your-role
# Initialize default scenario (creates molecule/default/ directory)
molecule init scenario --driver-name docker default
# Or create a new role with Molecule built-in
cd /path/to/roles
molecule init role my-new-role --driver-name docker
# Resulting structure:
# my-new-role/
# ├── defaults/
# │ └── main.yml
# ├── handlers/
# │ └── main.yml
# ├── molecule/
# │ └── default/
# │ ├── converge.yml # Playbook that calls your role
# │ ├── molecule.yml # Scenario configuration
# │ └── verify.yml # Verification playbook
# ├── tasks/
# │ └── main.yml
# └── meta/
# └── main.yml
# Run the full test sequence
molecule test
# Or run individual stages:
molecule create # Spin up test instances
molecule converge # Run the role
molecule verify # Run verification tests
molecule destroy # Tear down instances
# Re-run converge without destroying (useful during development)
molecule converge
molecule login # SSH into test container for debugging
Docker Driver Configuration
# molecule/default/molecule.yml
---
dependency:
name: galaxy
options:
requirements-file: requirements.yml
driver:
name: docker
platforms:
- name: ubuntu-22.04
image: geerlingguy/docker-ubuntu2204-ansible:latest
pre_build_image: true
privileged: false
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:ro
capabilities:
- SYS_ADMIN
command: /lib/systemd/systemd
# Environment variables for the container
env:
DEBIAN_FRONTEND: noninteractive
- name: centos-stream9
image: geerlingguy/docker-centos9-ansible:latest
pre_build_image: true
command: /usr/lib/systemd/systemd
capabilities:
- SYS_ADMIN
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:ro
provisioner:
name: ansible
log: true
inventory:
host_vars:
ubuntu-22.04:
ansible_user: root
centos-stream9:
ansible_user: root
env:
ANSIBLE_FORCE_COLOR: "1"
config_options:
defaults:
callback_whitelist: profile_tasks, timer
verifier:
name: ansible # Use Ansible for verification (alternative to Testinfra)
# name: testinfra # Use Python Testinfra
lint: |
set -e
yamllint .
ansible-lint
# molecule/default/converge.yml
---
- name: Converge
hosts: all
become: true
vars:
# Override defaults for testing
nginx_port: 8080
nginx_server_name: test.example.com
pre_tasks:
- name: Update apt cache
apt:
update_cache: true
cache_valid_time: 600
when: ansible_os_family == 'Debian'
roles:
- role: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') | basename }}"
Lint and Verify Stages
# Run linting only
molecule lint
# Configure yamllint
cat > .yamllint.yml <<EOF
---
extends: default
rules:
line-length:
max: 160
truthy:
allowed-values: ['true', 'false', 'yes', 'no']
braces:
min-spaces-inside: 0
max-spaces-inside: 1
brackets:
min-spaces-inside: 0
max-spaces-inside: 1
EOF
# Configure ansible-lint
cat > .ansible-lint <<EOF
---
skip_list:
- yaml[line-length]
- role-name
- no-changed-when
warn_list:
- command-instead-of-module
exclude_paths:
- .git/
- molecule/
EOF
# Verify stage uses either Ansible tasks or Testinfra
# Option 1: Ansible verify playbook
cat > molecule/default/verify.yml <<EOF
---
- name: Verify
hosts: all
become: true
gather_facts: false
tasks:
- name: Check nginx is running
service:
name: nginx
state: started
check_mode: true
register: nginx_service
- name: Assert nginx is running
assert:
that:
- not nginx_service.changed
fail_msg: "Nginx is not running"
- name: Check nginx responds on port 8080
uri:
url: http://localhost:8080
status_code: 200
register: nginx_response
- name: Check config file exists
stat:
path: /etc/nginx/sites-enabled/default
register: config_file
- name: Assert config file exists
assert:
that:
- config_file.stat.exists
fail_msg: "Nginx config not found"
EOF
Testinfra Assertions
# molecule/default/tests/test_default.py
# Used when verifier.name = testinfra
import pytest
import os
# Testinfra fixtures are automatically injected by pytest-testinfra
def test_nginx_installed(host):
"""Test that nginx package is installed"""
nginx = host.package("nginx")
assert nginx.is_installed
def test_nginx_running(host):
"""Test that nginx service is running and enabled"""
nginx = host.service("nginx")
assert nginx.is_running
assert nginx.is_enabled
def test_nginx_listening(host):
"""Test that nginx is listening on the correct port"""
# Check port 8080 (test config)
socket = host.socket("tcp://0.0.0.0:8080")
assert socket.is_listening
def test_nginx_config_file(host):
"""Test nginx configuration file"""
config = host.file("/etc/nginx/sites-enabled/default")
assert config.exists
assert config.is_file
assert config.mode == 0o644
# Check config contains expected content
assert "server_name test.example.com" in config.content_string
def test_nginx_user(host):
"""Test that www-data user exists for nginx"""
user = host.user("www-data")
assert user.exists
assert user.group == "www-data"
def test_nginx_log_dir(host):
"""Test log directory permissions"""
log_dir = host.file("/var/log/nginx")
assert log_dir.exists
assert log_dir.is_directory
# Nginx should own its log directory
assert log_dir.user == "root" or log_dir.user == "www-data"
def test_http_response(host):
"""Test HTTP response from nginx"""
cmd = host.run("curl -s -o /dev/null -w '%{http_code}' http://localhost:8080")
assert cmd.rc == 0
assert "200" in cmd.stdout
@pytest.mark.parametrize("port", [8080])
def test_firewall_allows_port(host, port):
"""Verify open ports"""
socket = host.socket(f"tcp://0.0.0.0:{port}")
assert socket.is_listening
# Test with different OS families
def test_package_manager_cache(host):
"""Test that package cache was updated"""
if host.system_info.distribution == "ubuntu":
apt_lists = host.file("/var/lib/apt/lists")
assert apt_lists.exists
elif host.system_info.distribution == "centos":
yum_cache = host.file("/var/cache/dnf")
assert yum_cache.exists
Multi-Platform Testing
# molecule.yml with multiple platforms and scenarios
platforms:
- name: ubuntu-2204
image: geerlingguy/docker-ubuntu2204-ansible:latest
pre_build_image: true
groups:
- debian
- name: ubuntu-2004
image: geerlingguy/docker-ubuntu2004-ansible:latest
pre_build_image: true
groups:
- debian
- name: debian-12
image: geerlingguy/docker-debian12-ansible:latest
pre_build_image: true
groups:
- debian
- name: rocky-9
image: geerlingguy/docker-rockylinux9-ansible:latest
pre_build_image: true
groups:
- rhel
- name: centos-stream9
image: geerlingguy/docker-centos9-ansible:latest
pre_build_image: true
groups:
- rhel
# converge.yml using OS-specific handling
---
- name: Converge
hosts: all
become: true
pre_tasks:
- name: Update package cache (Debian/Ubuntu)
apt:
update_cache: true
when: ansible_os_family == 'Debian'
- name: Update package cache (RHEL/CentOS)
dnf:
update_cache: true
when: ansible_os_family == 'RedHat'
roles:
- role: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') | basename }}"
Create multiple scenarios for different configurations:
# Create an additional scenario for testing different config options
molecule init scenario --driver-name docker ha-mode
# molecule/ha-mode/molecule.yml
# molecule/ha-mode/converge.yml (with ha_enabled: true vars)
# molecule/ha-mode/verify.yml
# Run a specific scenario
molecule test -s ha-mode
# Run all scenarios
molecule test --all
CI/CD Integration
# .github/workflows/molecule.yml
name: Molecule Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
scenario:
- default
- ha-mode
fail-fast: false
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -r requirements-dev.txt
- name: Run Molecule tests
run: |
cd roles/your-role
molecule test -s ${{ matrix.scenario }}
env:
PY_COLORS: '1'
ANSIBLE_FORCE_COLOR: '1'
# GitLab CI (.gitlab-ci.yml)
molecule:
stage: test
image: python:3.11
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
before_script:
- pip install molecule molecule-docker testinfra ansible-lint
- docker info
script:
- cd roles/your-role
- molecule test
parallel:
matrix:
- SCENARIO: [default, ha-mode]
Troubleshooting
Docker container fails to start:
# Check Docker daemon is running
docker info
# Try running the container manually with the same image
docker run -d --privileged \
-v /sys/fs/cgroup:/sys/fs/cgroup:ro \
geerlingguy/docker-ubuntu2204-ansible:latest \
/lib/systemd/systemd
# View container logs
molecule create
docker ps
docker logs <container-id>
Role fails during converge:
# Add verbose output
molecule converge -- -vvv
# Login to the container and debug manually
molecule login
# Now you're inside the container - run commands manually
# Check what state the container is in
molecule list
Testinfra tests failing:
# Run pytest with verbose output
cd roles/your-role
molecule converge
pytest molecule/default/tests/test_default.py -v \
--host=docker://ubuntu-22.04 \
--connection=docker
# Debug specific test
pytest molecule/default/tests/test_default.py::test_nginx_running -v -s
ansible-lint errors:
# Run lint manually to see all issues
ansible-lint roles/your-role/
# Fix specific rule violations
# FQCN required: use 'ansible.builtin.apt' instead of 'apt'
# no-changed-when: tasks that run commands need 'changed_when'
Conclusion
Molecule transforms Ansible role development from a manual testing process into an automated, repeatable CI/CD workflow that validates roles against multiple operating systems and configurations before deployment. By combining the Docker driver for fast instance creation, Testinfra for comprehensive assertions, and GitHub Actions or GitLab CI for automation, you build confidence that your roles work correctly on every platform they target. Start with a simple default scenario, add multi-platform testing as your role matures, and expand to multiple scenarios for different configuration modes.


