Skip to main content
RunBook Academy

AnsibleXXVI · Testing AutomationTesting automation

Molecule: a first scenario

Intermediate⏱ ~24 min🧪 Lab requiredmoleculeansible-playbookpodman

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

Not yet marked complete on this device.

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.

Configuration changemolecule init scenario
$ molecule init scenario
TASK [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.

FileWhat it isWho writes it
molecule.ymlscenario configuration: dependencies, the ansible settings, the sequencesgenerated, then edited
create.ymla playbook that brings the test instances into existenceyou
converge.ymla playbook that applies the role under testyou, usually one task
verify.ymla playbook that asserts the instance ended up correctyou — this is the actual test
destroy.ymla playbook that removes the test instancesyou

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:

Read-only / Safemolecule drivers
$ molecule drivers
default

The 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:

Read-only / Safemolecule/default/molecule.yml — the scenario block as generated
scenario:
name: default
test_sequence:
  - dependency
  - cleanup
  - destroy
  - syntax
  - create
  - prepare
  - converge
  - idempotence
  - side_effect
  - verify
  - cleanup
  - destroy

molecule matrix prints the same thing resolved against the files that actually exist, which is the fastest way to see what a scenario will do:

Read-only / Safemolecule matrix test
$ molecule matrix test
Test 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.yml

Reading the sequence in order:

  • dependency installs roles and collections from a requirements.yml, using ansible-galaxy underneath.
  • cleanup and destroy run first, deliberately. A scenario starts by removing anything a previous, failed run left behind.
  • syntax is the rung-1 check from lesson 1.
  • create runs your create.yml.
  • prepare is 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.
  • converge applies the role.
  • idempotence re-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_effect is for deliberately disturbing the instance — killing a service, corrupting a file — before verifying that the role repairs it.
  • verify runs your assertions.
  • cleanup and destroy run 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.

Read-only / Safemolecule/default/inventory.yml
---
all:
children:
  molecule:
    hosts:
      instance:
        container_image: docker.io/library/debian:12
    vars:
      ansible_connection: containers.podman.podman

Molecule needs to be told to use it, which is what the executor block in molecule.yml is for:

Read-only / Safemolecule/default/molecule.yml — the executor block
ansible:
executor:
  backend: ansible-playbook
  args:
    ansible_playbook:
      - --inventory=${MOLECULE_SCENARIO_DIRECTORY}/inventory.yml

create.yml

Configuration changemolecule/default/create.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

Configuration changemolecule/default/converge.yml
---
- name: Converge
hosts: molecule
gather_facts: true
tasks:
  - name: Apply the role under test
    ansible.builtin.include_role:
      name: webserver

verify.yml

Read-only / Safemolecule/default/verify.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

Destructivemolecule/default/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

Read-only / Safemolecule/default/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:

Configuration changethe development loop
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 finished

molecule 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

  1. Q1. You follow a tutorial that sets driver: name: docker in molecule.yml, and Molecule 26.x ignores it. Why?

  2. Q2. While developing a role you run molecule test after every edit. What is the practical cost?

  3. Q3. Which statements about a Molecule scenario on 26.x are correct? Select all that apply.

  4. 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.