Testing infrastructure code is just as critical as testing application code, yet it’s often overlooked. When your Ansible playbooks deploy to production, how confident are you that they’ll work correctly? This is where Molecule comes in—a powerful testing framework designed specifically for Ansible roles. In this comprehensive tutorial, you’ll learn how to implement robust testing strategies for your Ansible automation, from basic role testing to complex multi-scenario validations. Whether you’re managing a handful of servers or orchestrating thousands of nodes, Molecule will transform how you develop and maintain your infrastructure as code.
Prerequisites
Before diving into Molecule, ensure you have the following set up:
Required Tools:
- Python 3.8 or higher
- Ansible 2.10 or higher
- Docker (for container-based testing)
- pip (Python package manager)
Knowledge Requirements:
- Basic Ansible experience (roles, tasks, handlers)
- Familiarity with YAML syntax
- Understanding of Docker concepts
- Command-line proficiency
Environment Setup: I recommend creating a dedicated Python virtual environment to avoid dependency conflicts:
python3 -m venv molecule-env
source molecule-env/bin/activate # On Windows: molecule-env\Scripts\activate
Step 1: Installing Molecule
Molecule installation is straightforward with pip. You’ll need to install Molecule along with the Docker driver, which we’ll use for our testing scenarios.
# Install Molecule with Docker support
pip install "molecule[docker,lint]"
# Verify installation
molecule --version
Expected Output:
molecule 5.0.1 using python 3.11
ansible:2.15.4
default:5.0.1 from molecule
docker:2.1.0 from molecule_plugins.docker
The installation includes several components:
- Molecule core: The testing framework itself
- Docker driver: Enables container-based testing
- Lint tools: Ansible-lint and yamllint for code quality
Verification Checkpoint: Run molecule --version and confirm all components are installed. If Docker driver is missing, reinstall with the [docker] extra.
Step 2: Initializing a New Role
Let’s create a practical example: an Nginx web server role. Molecule integrates seamlessly with Ansible Galaxy’s role structure.
# Create a new role with Molecule testing built-in
molecule init role nginx --driver-name docker
# Navigate into the role directory
cd nginx
This command generates a complete role structure:
nginx/
├── defaults/
│ └── main.yml
├── files/
├── handlers/
│ └── main.yml
├── meta/
│ └── main.yml
├── molecule/
│ └── default/
│ ├── converge.yml
│ ├── molecule.yml
│ └── verify.yml
├── tasks/
│ └── main.yml
├── templates/
├── tests/
└── vars/
└── main.yml
Key Directories:
molecule/default/: Contains your test scenario configurationtasks/main.yml: Where your Ansible tasks gohandlers/main.yml: For service restarts and notifications
Step 3: Understanding Molecule Structure
The molecule/default/ directory contains three critical files:
molecule.yml - The main configuration file:
---
dependency:
name: galaxy
driver:
name: docker
platforms:
- name: instance
image: geerlingguy/docker-ubuntu2204-ansible:latest
pre_build_image: true
provisioner:
name: ansible
verifier:
name: ansible
Configuration Breakdown:
dependency: Manages role dependencies from Ansible Galaxydriver: Specifies Docker for spinning up test instancesplatforms: Defines test environments (OS, image, resources)provisioner: Uses Ansible to apply your roleverifier: Runs verification tests after convergence
converge.yml - Applies your role to test instances:
---
- name: Converge
hosts: all
become: true
tasks:
- name: "Include nginx"
include_role:
name: "nginx"
verify.yml - Contains assertions to validate role behavior:
---
- name: Verify
hosts: all
gather_facts: false
tasks:
- name: Example assertion
ansible.builtin.assert:
that: true
Step 4: Writing Your First Test
Let’s build a functional Nginx role with proper tests. Start by defining the role tasks.
tasks/main.yml:
---
# Install Nginx web server
- name: Install Nginx package
ansible.builtin.apt:
name: nginx
state: present
update_cache: yes
notify: Start nginx
- name: Ensure Nginx is started and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: yes
- name: Deploy custom index.html
ansible.builtin.copy:
content: |
<html>
<head><title>Ansible Managed</title></head>
<body><h1>Deployed with Ansible</h1></body>
</html>
dest: /var/www/html/index.html
owner: www-data
group: www-data
mode: '0644'
handlers/main.yml:
---
- name: Start nginx
ansible.builtin.service:
name: nginx
state: started
Now create meaningful verification tests in molecule/default/verify.yml:
---
- name: Verify
hosts: all
gather_facts: true
tasks:
- name: Check if Nginx is installed
ansible.builtin.package_facts:
manager: auto
- name: Assert Nginx package is present
ansible.builtin.assert:
that:
- "'nginx' in ansible_facts.packages"
fail_msg: "Nginx package is not installed"
success_msg: "Nginx package is correctly installed"
- name: Check if 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'"
- "ansible_facts.services['nginx.service'].status == 'enabled'"
fail_msg: "Nginx service is not running or not enabled"
success_msg: "Nginx service is active and enabled"
- name: Check if Nginx is listening on port 80
ansible.builtin.wait_for:
port: 80
timeout: 5
- name: Verify custom index.html content
ansible.builtin.uri:
url: http://localhost
return_content: yes
register: webpage
- name: Assert webpage contains expected content
ansible.builtin.assert:
that:
- "'Deployed with Ansible' in webpage.content"
fail_msg: "Custom index.html was not deployed correctly"
success_msg: "Custom index.html is correctly deployed"
Step 5: Running Tests
Molecule provides a complete test lifecycle. Here’s how to execute tests:
# Run the full test sequence
molecule test
# Or run individual steps:
molecule create # Create test instances
molecule converge # Apply your role
molecule verify # Run verification tests
molecule destroy # Clean up instances
The Full Test Sequence:
When you run molecule test, Molecule executes these phases:
- Dependency: Install role dependencies from Ansible Galaxy
- Lint: Check syntax and best practices
- Cleanup: Remove previous test instances
- Destroy: Ensure clean state
- Create: Spin up test containers
- Prepare: Optional pre-configuration steps
- Converge: Apply your role
- Idempotence: Re-run to ensure no changes occur
- Verify: Run assertion tests
- Destroy: Clean up resources
Expected Output (abbreviated):
--> Test matrix
└── default
├── dependency
├── cleanup
├── destroy
├── create
├── prepare
├── converge
├── idempotence
├── verify
└── destroy
--> Scenario: 'default'
--> Action: 'converge'
PLAY [Converge] ********************************************************
TASK [Include nginx] ***************************************************
included: /Users/user/nginx/tasks/main.yml
TASK [Install Nginx package] *******************************************
changed: [instance]
TASK [Ensure Nginx is started and enabled] *****************************
changed: [instance]
PLAY RECAP *************************************************************
instance: ok=3 changed=2 unreachable=0 failed=0
--> Scenario: 'default'
--> Action: 'verify'
TASK [Assert Nginx service is active] **********************************
ok: [instance] => {
"msg": "Nginx service is active and enabled"
}
Development Workflow Tip: During development, use molecule converge repeatedly to apply changes without destroying instances. Only run the full molecule test when you’re ready to validate everything.
Step 6: Testing with Different Scenarios
Real-world infrastructure rarely runs on a single OS. Molecule scenarios let you test across multiple platforms simultaneously.
Create a multi-platform scenario by modifying molecule/default/molecule.yml:
---
dependency:
name: galaxy
driver:
name: docker
platforms:
- name: ubuntu-20-04
image: geerlingguy/docker-ubuntu2004-ansible:latest
pre_build_image: true
- name: ubuntu-22-04
image: geerlingguy/docker-ubuntu2204-ansible:latest
pre_build_image: true
- name: debian-11
image: geerlingguy/docker-debian11-ansible:latest
pre_build_image: true
provisioner:
name: ansible
playbooks:
converge: converge.yml
verify: verify.yml
verifier:
name: ansible
Creating Named Scenarios:
You can also create completely separate test scenarios for different use cases:
# Create a production-like scenario
molecule init scenario production
# Create a scenario for testing upgrades
molecule init scenario upgrade
This creates:
molecule/
├── default/
│ ├── converge.yml
│ ├── molecule.yml
│ └── verify.yml
├── production/
│ ├── converge.yml
│ ├── molecule.yml
│ └── verify.yml
└── upgrade/
├── converge.yml
├── molecule.yml
└── verify.yml
Running Specific Scenarios:
# Test only the production scenario
molecule test -s production
# Converge multiple scenarios
molecule converge --all
Step 7: CI/CD Integration
Molecule integrates seamlessly with continuous integration pipelines. Here’s how to set it up with GitHub Actions.
.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
- production
steps:
- name: Check out code
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install "molecule[docker,lint]"
pip install ansible
- name: Run Molecule tests
run: molecule test -s ${{ matrix.scenario }}
GitLab CI Configuration (.gitlab-ci.yml):
---
stages:
- test
molecule:
stage: test
image: geerlingguy/docker-ubuntu2204-ansible:latest
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
before_script:
- pip install "molecule[docker,lint]"
script:
- molecule test --all
only:
- merge_requests
- main
Verification Checkpoint: Push your role to a Git repository and verify the CI pipeline executes successfully. Check that all scenarios pass and the pipeline fails on test failures.
Real-world Examples
Example 1: Testing a Web Server Role
Let’s extend our Nginx role with SSL configuration and virtual hosts.
tasks/main.yml (extended):
---
- name: Install Nginx and SSL dependencies
ansible.builtin.apt:
name:
- nginx
- openssl
state: present
update_cache: yes
- name: Create SSL directory
ansible.builtin.file:
path: /etc/nginx/ssl
state: directory
mode: '0755'
- name: Generate self-signed SSL certificate
ansible.builtin.command:
cmd: >
openssl req -x509 -nodes -days 365 -newkey rsa:2048
-keyout /etc/nginx/ssl/nginx.key
-out /etc/nginx/ssl/nginx.crt
-subj "/C=US/ST=State/L=City/O=Org/CN=localhost"
creates: /etc/nginx/ssl/nginx.crt
- name: Deploy SSL virtual host configuration
ansible.builtin.template:
src: vhost-ssl.conf.j2
dest: /etc/nginx/sites-available/default
mode: '0644'
notify: Reload nginx
- name: Ensure Nginx is started
ansible.builtin.service:
name: nginx
state: started
enabled: yes
templates/vhost-ssl.conf.j2:
server {
listen 80;
listen 443 ssl;
ssl_certificate /etc/nginx/ssl/nginx.crt;
ssl_certificate_key /etc/nginx/ssl/nginx.key;
root /var/www/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
molecule/default/verify.yml (extended):
---
- name: Verify
hosts: all
gather_facts: true
tasks:
- name: Check SSL certificate exists
ansible.builtin.stat:
path: /etc/nginx/ssl/nginx.crt
register: ssl_cert
- name: Assert SSL certificate is present
ansible.builtin.assert:
that:
- ssl_cert.stat.exists
- ssl_cert.stat.mode == '0644'
- name: Verify Nginx is listening on HTTPS
ansible.builtin.wait_for:
port: 443
timeout: 5
- name: Test HTTPS connection
ansible.builtin.uri:
url: https://localhost
validate_certs: no
return_content: yes
register: https_response
- name: Assert HTTPS is working
ansible.builtin.assert:
that:
- https_response.status == 200
Example 2: Testing Database Configuration
Here’s how to test a PostgreSQL role with Molecule.
molecule/default/molecule.yml:
---
platforms:
- name: postgres-instance
image: geerlingguy/docker-ubuntu2204-ansible:latest
pre_build_image: true
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:ro
privileged: true
command: /lib/systemd/systemd
tasks/main.yml (PostgreSQL role):
---
- name: Install PostgreSQL
ansible.builtin.apt:
name:
- postgresql
- postgresql-contrib
- python3-psycopg2
state: present
update_cache: yes
- name: Ensure PostgreSQL is started
ansible.builtin.service:
name: postgresql
state: started
enabled: yes
- name: Create application database
become_user: postgres
community.postgresql.postgresql_db:
name: appdb
state: present
- name: Create database user
become_user: postgres
community.postgresql.postgresql_user:
name: appuser
password: "{{ db_password | default('changeme') }}"
db: appdb
priv: ALL
state: present
molecule/default/verify.yml:
---
- name: Verify
hosts: all
become: true
tasks:
- name: Check PostgreSQL service status
ansible.builtin.service_facts:
- name: Assert PostgreSQL is running
ansible.builtin.assert:
that:
- "ansible_facts.services['postgresql.service'].state == 'running'"
- name: Verify database exists
become_user: postgres
community.postgresql.postgresql_query:
db: appdb
query: SELECT 1
register: db_check
- name: Assert database is accessible
ansible.builtin.assert:
that:
- db_check is succeeded
- name: Test database connection with user
become_user: postgres
community.postgresql.postgresql_query:
db: appdb
login_user: appuser
query: CREATE TABLE test (id serial PRIMARY KEY, name varchar(50))
register: table_creation
- name: Assert user has correct privileges
ansible.builtin.assert:
that:
- table_creation is succeeded
Example 3: Testing Security Hardening
Security roles require thorough testing to ensure they don’t break functionality.
molecule/default/verify.yml (security hardening):
---
- name: Verify Security Hardening
hosts: all
gather_facts: true
tasks:
- name: Check if SSH password authentication is disabled
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^PasswordAuthentication'
line: 'PasswordAuthentication no'
state: present
check_mode: yes
register: ssh_config
- name: Assert SSH is properly configured
ansible.builtin.assert:
that:
- not ssh_config.changed
fail_msg: "SSH password authentication is not disabled"
- name: Verify firewall is active
ansible.builtin.command: ufw status
register: ufw_status
changed_when: false
- name: Assert firewall is enabled
ansible.builtin.assert:
that:
- "'Status: active' in ufw_status.stdout"
- name: Check for unattended upgrades
ansible.builtin.stat:
path: /etc/apt/apt.conf.d/50unattended-upgrades
register: unattended_upgrades
- name: Assert automatic updates are configured
ansible.builtin.assert:
that:
- unattended_upgrades.stat.exists
- name: Verify root login is disabled
ansible.builtin.command: passwd -S root
register: root_status
changed_when: false
- name: Assert root account is locked
ansible.builtin.assert:
that:
- "'L' in root_status.stdout"
fail_msg: "Root account is not locked"
Best Practices
Test Organization
Keep tests focused and modular:
# Bad: One giant verification file
verify.yml # 500 lines of tests
# Good: Separate verification tasks
molecule/default/
├── molecule.yml
├── converge.yml
├── verify.yml
└── verify/
├── test_installation.yml
├── test_configuration.yml
├── test_security.yml
└── test_networking.yml
Main verify.yml includes specific tests:
---
- name: Verify
hosts: all
tasks:
- name: Run installation tests
include_tasks: verify/test_installation.yml
- name: Run configuration tests
include_tasks: verify/test_configuration.yml
- name: Run security tests
include_tasks: verify/test_security.yml
Scenario Management
Use scenarios strategically:
default: Basic functionality testingproduction: Production-like environment with all featuresupgrade: Testing upgrade paths from previous versionsmulti-node: Distributed system testing
Don’t create scenarios for minor variations. Instead, use variables:
# molecule/default/converge.yml
- name: Converge
hosts: all
vars:
# Override these in specific scenarios
enable_ssl: "{{ scenario_ssl | default(true) }}"
enable_monitoring: "{{ scenario_monitoring | default(false) }}"
roles:
- nginx
Idempotency Testing
Idempotency is critical—running your role multiple times should not cause changes after the first run.
Molecule automatically tests this. Ensure your tasks are idempotent:
# Bad: Always reports changed
- name: Configure application
ansible.builtin.shell: echo "config=true" >> /etc/app.conf
# Good: Idempotent configuration
- name: Configure application
ansible.builtin.lineinfile:
path: /etc/app.conf
line: "config=true"
create: yes
Monitor idempotence test output:
PLAY RECAP *****************************************************************
instance: ok=10 changed=0 unreachable=0 failed=0
If changed is greater than 0 on the second run, you have idempotency issues.
Docker vs Vagrant
Docker (Recommended for most cases):
- Fast: Containers start in seconds
- Resource-efficient: Run many instances simultaneously
- CI-friendly: Works in most CI environments
- Limitations: Cannot test kernel modules, systemd quirks, or anything requiring full VM
Vagrant (For complex scenarios):
- Full VMs: Complete operating system
- Systemd: Full init system support
- Kernel testing: Can test kernel parameters and modules
- Trade-offs: Slower (minutes to start), resource-heavy, harder in CI
When to use Vagrant:
# molecule/vagrant/molecule.yml
---
driver:
name: vagrant
platforms:
- name: ubuntu-vm
box: ubuntu/jammy64
memory: 2048
cpus: 2
provisioner:
name: ansible
Use Vagrant when testing roles that:
- Modify kernel parameters (
sysctl) - Require specific systemd features
- Install kernel modules
- Need full networking stack
Common Pitfalls
Container Limitations
Problem: Systemd doesn’t work properly in standard containers.
Solution: Use pre-built images with systemd support:
platforms:
- name: instance
image: geerlingguy/docker-ubuntu2204-ansible:latest
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:ro
privileged: true
command: /lib/systemd/systemd
Jeff Geerling’s Docker images are specifically built for Ansible testing with working systemd.
Privilege Issues
Problem: Tasks fail with permission errors.
Solution: Use become appropriately in your converge playbook:
# molecule/default/converge.yml
---
- name: Converge
hosts: all
become: true # Run tasks with sudo
tasks:
- name: Include role
include_role:
name: nginx
Avoid requiring become: yes in the role itself when possible. Let the user decide privilege escalation.
Dependency Management
Problem: Role depends on other roles, but they’re not available during testing.
Solution: Define dependencies in meta/main.yml:
# meta/main.yml
---
dependencies:
- role: geerlingguy.security
- role: geerlingguy.firewall
Molecule automatically installs these before testing if you have the Galaxy dependency manager enabled:
# molecule/default/molecule.yml
dependency:
name: galaxy
options:
role-file: requirements.yml
Create requirements.yml for external dependencies:
# requirements.yml
---
roles:
- name: geerlingguy.security
version: 2.3.0
- name: geerlingguy.firewall
version: 3.1.0
Test Flakiness
Problem: Tests occasionally fail for no apparent reason.
Common causes:
- Network timeouts
- Race conditions in service startup
- Insufficient wait times
Solution: Add appropriate waits and retries:
- name: Wait for service to be ready
ansible.builtin.wait_for:
port: 80
delay: 2
timeout: 30
- name: Check endpoint with retry
ansible.builtin.uri:
url: http://localhost/health
register: health_check
until: health_check.status == 200
retries: 5
delay: 3
Tools and Integration
Testinfra for Verification
While Molecule uses Ansible for verification by default, you can also use Testinfra (Python-based testing):
pip install testinfra
molecule/default/tests/test_default.py:
import os
import testinfra.utils.ansible_runner
testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner(
os.environ['MOLECULE_INVENTORY_FILE']
).get_hosts('all')
def test_nginx_is_installed(host):
nginx = host.package("nginx")
assert nginx.is_installed
def test_nginx_running_and_enabled(host):
nginx = host.service("nginx")
assert nginx.is_running
assert nginx.is_enabled
def test_nginx_listening(host):
assert host.socket("tcp://0.0.0.0:80").is_listening
def test_index_html_content(host):
content = host.file("/var/www/html/index.html").content_string
assert "Deployed with Ansible" in content
Configure Testinfra in molecule.yml:
verifier:
name: testinfra
options:
v: 1
GitLab/GitHub CI Integration
Advanced GitHub Actions with matrix testing:
---
name: Ansible Role Testing
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 0 * * 0' # Weekly
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: pip install ansible-lint yamllint
- name: Lint Ansible role
run: ansible-lint .
- name: Lint YAML files
run: yamllint .
test:
needs: lint
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
distro:
- ubuntu2004
- ubuntu2204
- debian11
scenario:
- default
- production
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install Molecule
run: pip install "molecule[docker,lint]" ansible
- name: Run Molecule test
run: molecule test -s ${{ matrix.scenario }}
env:
PY_COLORS: '1'
ANSIBLE_FORCE_COLOR: '1'
MOLECULE_DISTRO: ${{ matrix.distro }}
Ansible Galaxy Integration
When publishing roles to Ansible Galaxy, include Molecule tests to boost credibility:
# .github/workflows/release.yml
---
name: Release to Galaxy
on:
push:
tags:
- 'v*'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Test with Molecule
run: |
pip install "molecule[docker,lint]"
molecule test
release:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Release to Galaxy
uses: robertdebock/galaxy-action@1.2.0
with:
galaxy_api_key: ${{ secrets.GALAXY_API_KEY }}
Link to your CI badge in README.md:
# Ansible Role: Nginx
[](https://github.com/username/ansible-role-nginx/actions)
[](https://galaxy.ansible.com/username/nginx)
Tested with Molecule on:
- Ubuntu 20.04
- Ubuntu 22.04
- Debian 11
Conclusion
Molecule transforms Ansible role development from a manual, error-prone process into a robust, automated workflow. By implementing comprehensive testing strategies—from basic functionality checks to multi-platform scenarios—you ensure your infrastructure code is reliable, maintainable, and production-ready.
Key Takeaways:
- Always test your roles across multiple operating systems and scenarios
- Leverage idempotency testing to prevent configuration drift
- Integrate Molecule into your CI/CD pipeline for continuous validation
- Use Docker for speed during development, Vagrant for complex system-level testing
- Write clear, focused verification tests that act as living documentation
Next Steps:
- Explore advanced scenarios: Implement multi-node testing for distributed systems (learn more in our Docker Container Management Best Practices guide)
- Integrate with your CI/CD pipeline: Add Molecule tests to your existing pipelines (check out CI/CD Pipeline Optimization Strategies)
- Contribute to the community: Share your tested roles on Ansible Galaxy
- Learn complementary testing tools: Explore Testinfra, InSpec, and ServerSpec for deeper infrastructure validation
For more on infrastructure automation and DevOps best practices, explore our comprehensive guide on Infrastructure as Code with Terraform or dive into Kubernetes Deployment Strategies for cloud-native infrastructure testing.
Additional Resources:
- Official Molecule Documentation
- Ansible Testing Strategies Guide
- Jeff Geerling’s Ansible Testing Series
Start testing your infrastructure code today—your future self (and your team) will thank you when deployments become predictable, reliable, and stress-free.