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.