AnsibleXXVI · Testing AutomationTesting automation
Molecule: a first scenario
What you'll learn
- Describe what a Molecule scenario is and which files make it up on 26.x
- Explain the default test sequence and what each action does
- Write a create.yml and destroy.yml under the default driver
- Run converge, idempotence and verify separately while developing a role
- Recognise which parts of a scenario are Molecule and which are ordinary Ansible
Prerequisites
Practice
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Molecule describes itself as “an Ansible testing framework designed for developing and testing Ansible collections, playbooks, and roles”, and it supports “only the latest two major versions of Ansible (N/N-1)”.
That support statement is the first practical thing to know about it. Molecule is not a stable API you pin once and forget; it tracks ansible-core closely, and a controller more than one major version behind is out of support. The course targets Molecule 26.x against ansible-core 2.21.
This lesson is a first exposure, deliberately. It builds one scenario small enough to actually run, and it does not attempt to be a Molecule reference.
A scenario is a directory of playbooks
Almost everything in Molecule turns out to be ordinary Ansible. A
scenario is a directory under molecule/, and the framework’s job is to
run the playbooks in it, in a defined order, and to interpret the
results.
$ molecule init scenarioTASK [Expand templates] ********************************************************
changed: [localhost] => (item=molecule/default/verify.yml)
changed: [localhost] => (item=molecule/default/molecule.yml)
changed: [localhost] => (item=molecule/default/destroy.yml)
changed: [localhost] => (item=molecule/default/create.yml)
changed: [localhost] => (item=molecule/default/converge.yml)
INFO [default > init] Initialized scenario in .../molecule/default successfully.Five files, not three. That number is the main thing that has changed about Molecule in recent years, and older tutorials will tell you otherwise.
| File | What it is | Who writes it |
|---|---|---|
molecule.yml | scenario configuration: dependencies, the ansible settings, the sequences | generated, then edited |
create.yml | a playbook that brings the test instances into existence | you |
converge.yml | a playbook that applies the role under test | you, usually one task |
verify.yml | a playbook that asserts the instance ended up correct | you — this is the actual test |
destroy.yml | a playbook that removes the test instances | you |
Two more files appear in most real scenarios and are not generated: a
requirements.yml naming the collections the create playbook needs, and
an inventory.yml declaring the instances.
create.yml and destroy.yml being your responsibility is the second
surprise, and it follows from the driver model.
There is one driver, and it is called default
Older Molecule shipped driver plugins — docker, podman, vagrant,
ec2 — that knew how to create instances. Molecule 26.x does not:
$ molecule driversdefaultThe default driver means Ansible creates the instances. Molecule
calls your create.yml, your create.yml starts containers or VMs
using whatever collection is appropriate, and your destroy.yml removes
them. Molecule’s contribution is the ordering, the ephemeral inventory,
and the idempotence check.
This is more work than a driver plugin and considerably less magic. It
also means everything you already know about Ansible applies: if you can
write a play that starts a container, you can write create.yml.
The default test sequence
molecule test runs the full lifecycle. The sequence is written into
the generated molecule.yml, so it is visible and editable rather than
hidden in the tool:
scenario:
name: default
test_sequence:
- dependency
- cleanup
- destroy
- syntax
- create
- prepare
- converge
- idempotence
- side_effect
- verify
- cleanup
- destroymolecule matrix prints the same thing resolved against the files that
actually exist, which is the fastest way to see what a scenario will do:
$ molecule matrix testTest matrix
-----------
default
├─ dependency Missing playbook (remove from test_sequence to suppress)
├─ cleanup Missing playbook (remove from test_sequence to suppress)
├─ destroy molecule/default/destroy.yml
├─ syntax Missing playbook (remove from test_sequence to suppress)
├─ create molecule/default/create.yml
├─ prepare Missing playbook (remove from test_sequence to suppress)
├─ converge molecule/default/converge.yml
├─ idempotence Missing playbook (remove from test_sequence to suppress)
├─ side_effect Missing playbook (remove from test_sequence to suppress)
├─ verify molecule/default/verify.yml
├─ cleanup Missing playbook (remove from test_sequence to suppress)
└─ destroy molecule/default/destroy.ymlReading the sequence in order:
dependencyinstalls roles and collections from arequirements.yml, usingansible-galaxyunderneath.cleanupanddestroyrun first, deliberately. A scenario starts by removing anything a previous, failed run left behind.syntaxis the rung-1 check from lesson 1.createruns yourcreate.yml.prepareis for one-time setup the role should not have to do — installing Python on a minimal image, adding a repository. Anything you put here is not being tested.convergeapplies the role.idempotencere-runs converge and fails if anything reports changed. Its help text: “Use the provisioner to configure the instances. After parse the output to determine idempotence.” There is no playbook to write for this step — the “Missing playbook” line above is the matrix noting the absence of an optional hook, not a warning.side_effectis for deliberately disturbing the instance — killing a service, corrupting a file — before verifying that the role repairs it.verifyruns your assertions.cleanupanddestroyrun again at the end.
Other sequences exist for the sub-commands: converge_sequence is
dependency, create, prepare, converge, and destroy_sequence is
dependency, cleanup, destroy. Those are what make Molecule usable
while writing a role, which is the next section.
A first scenario, end to end
The role under test is deliberately trivial: install a package and render a configuration file. Nothing that needs systemd, so a plain container is an honest environment for it.
inventory.yml
The instances are declared as ordinary inventory, inside the scenario
directory. This is the “Ansible native inventory” shape the upstream
examples use, and it is the simplest thing that works: create.yml
reads the list of hosts from a group rather than from a Molecule-specific
structure.
---
all:
children:
molecule:
hosts:
instance:
container_image: docker.io/library/debian:12
vars:
ansible_connection: containers.podman.podmanMolecule needs to be told to use it, which is what the executor block
in molecule.yml is for:
ansible:
executor:
backend: ansible-playbook
args:
ansible_playbook:
- --inventory=${MOLECULE_SCENARIO_DIRECTORY}/inventory.ymlcreate.yml
---
- name: Create
hosts: localhost
gather_facts: false
tasks:
- name: Start the test containers
containers.podman.podman_container:
name: "{{ item }}"
image: "{{ hostvars[item]['container_image'] }}"
command: sleep 1d
state: started
loop: "{{ groups['molecule'] }}"
- name: Wait for the containers to accept connections
ansible.builtin.wait_for_connection:
timeout: 30
delegate_to: "{{ item }}"
loop: "{{ groups['molecule'] }}"Three things there are worth naming. The container is created with a
long-running command because a container exits when its entrypoint
does, and Ansible needs it to stay up. The connection is
containers.podman.podman, not SSH — Molecule scenarios use a container
connection plugin, which is one of the reasons a scenario and a
production run prove different things. And the loop is over
groups['molecule'], so adding a second host to inventory.yml is the
only change needed to test two instances.
converge.yml
---
- name: Converge
hosts: molecule
gather_facts: true
tasks:
- name: Apply the role under test
ansible.builtin.include_role:
name: webserververify.yml
---
- name: Verify
hosts: molecule
gather_facts: false
tasks:
- name: Read the rendered configuration
ansible.builtin.slurp:
src: /etc/nginx/conf.d/site.conf
register: site_conf
- name: The configuration names the expected server
ansible.builtin.assert:
that:
- "'server_name web.example.com;' in (site_conf.content | b64decode)"
fail_msg: 'site.conf does not contain the expected server_name'
success_msg: 'site.conf renders the expected server_name'destroy.yml
---
- name: Destroy
hosts: localhost
gather_facts: false
tasks:
- name: Remove the test containers
containers.podman.podman_container:
name: "{{ item }}"
state: absent
loop: "{{ groups['molecule'] }}"requirements.yml
---
collections:
- name: containers.podman
version: '>=1.15.0'Running it while you work
molecule test is the full lifecycle and it destroys the instance at
both ends, which makes it the wrong command to use while developing.
The loop that works is:
molecule converge # create if needed, then apply the role
molecule idempotence # re-run converge, fail on any change
molecule verify # run the assertions
molecule login # shell into the instance to look around
molecule destroy # tear it down when finishedmolecule converge is the one you will run fifty times. It creates the
instance if it does not exist and applies the role again if it does, so
the edit-run cycle costs one role application rather than a full
create-and-destroy.
molecule login is the debugging tool. When verify fails and you
cannot see why, opening a shell in the instance that failed is faster
than adding debug tasks.
Knowledge check
Knowledge check · 4 questions
Q1. You follow a tutorial that sets driver: name: docker in molecule.yml, and Molecule 26.x ignores it. Why?
Q2. While developing a role you run molecule test after every edit. What is the practical cost?
Q3. Which statements about a Molecule scenario on 26.x are correct? Select all that apply.
Q4. A Molecule scenario exercises the role over a container connection plugin rather than SSH, which is one reason a passing scenario and a production run prove different things.
Passing score: 75%. Answers are checked in this browser.