Testing Ansible Playbooks with Molecule

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:

  1. Create — spin up test instances (Docker, Vagrant, EC2, etc.)
  2. Prepare — run prerequisite tasks
  3. Converge — apply your role/playbook
  4. Verify — assert the desired state
  5. 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 --version

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

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

Testing 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: nginx

Pass 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 != 0

These 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-ubuntu

During 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: 1

When 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: true

Linting 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: error

Common rules it enforces:

  • Use modules instead of shell commands when a module exists
  • Always set changed_when for command/shell tasks
  • 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 docker
molecule/
├── 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 works

Run a specific scenario:

molecule test --scenario-name with-ssl

Using 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: present

End-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 test runs 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.

Read more