How to setup test-driven development with Molecule in Ansible
Molecule is a testing framework designed for Ansible roles, collections, and playbooks. In the Ansible ecosystem, it provides a structured workflow to develop and verify infrastructure as code.
From a broader perspective, Molecule encourages test-driven infrastructure development (TDD). Instead of deploying roles directly to production and hoping they work, developers can validate them locally or in CI pipelines with Molecule. It supports different testing strategies, linting, idempotence, and a plugin ecosystem.
While being a little tricky to setup, Molecule enables the developer the Correct Thinking (TM) formalisms. TDD allows the developer to validate a finished step, and mark it as complete. TDD allows thinking in pieces: rather than being overwhelmed by a large task, the developer can implement and validate small pieces of the functionality being implemented - and with such incremental changes, the whole project can be completed.
~ * ~ * ~ * ~
Molecule underwent breaking changes between some releases, and some documentation online appears to be incorrect. This article works with Molecule version 25. Here, I'm just going to setup a minimal scaffold, to enable running a simple test on a single remove machine. The reader can take it from there, customizing the scaffold for their own specific needs.
Let's assume we have an ansible project with the following folder structure:
wco_ansible/
inventory/
ants.yml
do.yml
playbooks/
setup_mongo.yml
setup_ubuntu.yml
requirements.yml
roles/
ror/
files/
root/
_screenrc
meta/
main.yml
molecule/
...
requirements.yml
tasks/
packaged_app.yml
templates/
etc/
mongo.conf
vars/
packaged_app_1.yml
We will be testing a specific task in the role ror. First, let's setup the structure of wco_ansible/roles/ror/molecule like so:
wco_ansible/roles/ror/molecule/
README.txt
default/
converge.yml
create.yml
destroy.yml
molecule.yml
verify.yml
And the contents of the files:
## requirements.yml
collections:
- name: community.docker
version: ">=3.10.4"
- name: community.general
.
## converge.yml
- name: Fail if molecule group is missing
hosts: localhost
tasks:
- name: Print some info
ansible.builtin.debug:
msg: "{{ groups }}"
- name: Assert group existence
ansible.builtin.assert:
that: "'molecule' in groups"
fail_msg: |
molecule group was not found inside inventory groups: {{ groups }}
- name: Converge
hosts: molecule
# We disable gather facts because it would fail due to our container not
# having python installed. This will not prevent use from running 'raw'
# commands. Most molecule users are expected to use containers that already
# have python installed in order to avoid notable delays installing it.
# gather_facts: false
tasks:
- name: Check uname
ansible.builtin.raw: uname -a
register: result
changed_when: false
- name: Print some info
ansible.builtin.assert:
that: result.stdout is ansible.builtin.search("^Linux")
- name: "+++ +++ Check Python version is >= 3.8"
command: python3 --version
register: py_ver
changed_when: false
- name: "+++ +++ Assert correct Python version"
assert:
that:
- py_ver.stdout is match("Python 3\\.[189].*|Python [4-9].*")
The following is important. Specifically, adding container to molevule_inventory seems to matter.
## create.yml
- name: Create
hosts: localhost
gather_facts: false
vars:
molecule_inventory:
all:
hosts: {}
# molecule: {}
tasks:
- name: Create a container
community.docker.docker_container:
name: "{{ item.name }}"
image: "{{ item.image }}"
state: started
command: sleep 1d
log_driver: json-file
register: result
loop: "{{ molecule_yml.platforms }}"
- name: Print some info
ansible.builtin.debug:
msg: "{{ result.results }}"
- name: Fail if container is not running
when: >
item.container.State.ExitCode != 0 or
not item.container.State.Running
ansible.builtin.include_tasks:
file: tasks/create-fail.yml
loop: "{{ result.results }}"
loop_control:
label: "{{ item.container.Name }}"
- name: Add container to molecule_inventory
vars:
inventory_partial_yaml: |
all:
children:
molecule:
hosts:
"{{ item.name }}":
ansible_connection: {{ item.ansible_connection }}
ansible_user: {{ item.ansible_user }}
ansible_host: {{ item.ansible_host }}
ansible_port: {{ item.ansible_port }}
ansible_ssh_common_args: {{ item.ansible_ssh_common_args }}
ansible_ssh_private_key_file: {{ item.ansible_ssh_private_key_file }}
ansible.builtin.set_fact:
molecule_inventory: >
{{ molecule_inventory | combine(inventory_partial_yaml | from_yaml, recursive=true) }}
loop: "{{ molecule_yml.platforms }}"
loop_control:
label: "{{ item.name }}"
- name: Dump molecule_inventory
ansible.builtin.copy:
content: |
{{ molecule_inventory | to_yaml }}
dest: "{{ molecule_ephemeral_directory }}/inventory/molecule_inventory.yml"
mode: "0600"
- name: Force inventory refresh
ansible.builtin.meta: refresh_inventory
- name: Fail if molecule group is missing
ansible.builtin.assert:
that: "'molecule' in groups"
fail_msg: |
molecule group was not found inside inventory groups: {{ groups }}
run_once: true # noqa: run-once[task]
# we want to avoid errors like "Failed to create temporary directory"
- name: Validate that inventory was refreshed
hosts: molecule
gather_facts: false
tasks:
- name: Check uname
ansible.builtin.raw: uname -a
register: result
changed_when: false
- name: Display uname info
ansible.builtin.debug:
msg: "{{ result.stdout }}"
.
## destroy.yml
- name: Destroy molecule containers
hosts: molecule
gather_facts: false
tasks:
- name: Stop and remove container
delegate_to: localhost
community.docker.docker_container:
name: "{{ inventory_hostname }}"
state: absent
auto_remove: true
- name: Remove dynamic molecule inventory
hosts: localhost
gather_facts: false
tasks:
- name: Remove dynamic inventory file
ansible.builtin.file:
path: "{{ molecule_ephemeral_directory }}/inventory/molecule_inventory.yml"
state: absent
In the following file, you want to have both platforms and provisioner keys. They hold the same data for me... The host, port, and other arguments in provisioner are definitely important. The ones in platforms may be optional - but I use them in create.yml so for now they have to stay. Of note, the structure of platforms can probably be simplified:
## molecule.yml
dependency:
name: galaxy
options:
requirements-file: requirements.yml
# driver:
# name: ssh
platforms:
- name: do-ubuntu24-3
ansible_connection: ssh
ansible_user: root
ansible_host: "x.x.x.x"
ansible_port: 22
ansible_ssh_common_args: "-o StrictHostKeyChecking=no"
ansible_ssh_private_key_file: ~/.ssh/your-key
groups:
- molecule
image: piousbox/ubuntu-sshd:0.0.0 ## not used, but presence is required
provisioner:
name: ansible
inventory:
host_vars:
do-ubuntu24-3:
ansible_connection: ssh
ansible_host: x.x.x.x
ansible_port: 22
ansible_user: root
ansible_ssh_common_args: "-o StrictHostKeyChecking=no"
ansible_ssh_private_key_file: ~/.ssh/your-key
Now, a comment on ssh vs docker driver. I use both. Since I drive docker with ansible, often I need docker inside docker, or something similar, and in the end it is easier for me to spin up a virtual machine elsewhere and do testing there via ssh. On the other hand, the development laptop definitely uses a lot of docker, so the docker driver for molecule is valuable and useful. I have my own image that allows systemd, and ssh. You can read the article on How I set up the docker image with systemd and sshd, for more info on the topic.
.^.