Ansible Molecule with Docker: Testing Roles Properly From Scratch

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-lint

You also need Docker installed and running.

Creating a Molecule Scenario

Initialize Molecule in an existing role:

cd roles/nginx
molecule init scenario --driver-name docker

This 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 setup

molecule.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-lint

converge.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: nginx

verify.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 debugging

Testing 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/systemd

Now 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 docker
molecule/
  default/         # Standard installation
  minimal/         # Minimal config, no extra modules
  with-ssl/        # With SSL cert configuration
  clustered/       # Multiple nodes

Run a specific scenario:

molecule test --scenario-name with-ssl

prepare.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: present

CI 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/nginx

Or run it separately:

ansible-lint roles/nginx/

Common issues ansible-lint catches:

  • command used instead of appropriate module
  • Missing name on tasks
  • become: true at 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 -t

Conclusion

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.

Read more

Start now free