Molecule is where an Ansible role stops being plausible YAML and starts being tested automation.
Lint tells you the role is well-formed. Molecule tells you it works — on a real target, twice in a row, producing the state it claims to.
What Molecule does
Section titled “What Molecule does”It manages a test lifecycle around a role.
Create a target — a container, a VM, or whatever your driver provisions.
Converge — run the role against it.
Idempotence — run it again and assert nothing changed.
Verify — assert the target is in the expected state.
Destroy — remove the target.
molecule test runs the whole sequence including a lint step and a cleanup; molecule converge and molecule verify iterate faster while you are developing.
The property that matters: the target is created and destroyed by the test. It is never a host from your inventory, which is what makes this safe to run on any pull request from anybody — the worst outcome of a malicious change is a container that misbehaves and is then thrown away.
Scenarios
Section titled “Scenarios”A scenario is a directory describing one way of testing the role.
roles/local/app_deploy/├── defaults/├── tasks/└── molecule/ ├── default/ │ ├── molecule.yml # Driver, platforms, provisioner │ ├── converge.yml # The playbook that applies the role │ └── verify.yml # Assertions └── upgrade/ ├── molecule.yml ├── prepare.yml # Set up a prior state ├── converge.yml └── verify.ymldefault is the scenario molecule test runs with no arguments.
Additional scenarios test different situations. An upgrade scenario that uses prepare.yml to establish an older state before converging is how you test that a role works on a host that is not empty — which is the case that actually breaks in production, and the case a default scenario never exercises.
Scenarios live inside the role, which means extracting the role later takes its tests with it.
Configuration
Section titled “Configuration”driver: name: docker
platforms: - name: instance-debian image: docker.io/library/debian:13 pre_build_image: true command: /lib/systemd/systemd cgroupns_mode: host privileged: true volumes: - /sys/fs/cgroup:/sys/fs/cgroup:rw
provisioner: name: ansible config_options: defaults: callbacks_enabled: profile_tasks
verifier: name: ansibledriver.name: docker uses the bundled Docker playbooks. podman likewise. Anything else needs the corresponding plugin installed.
The systemd configuration — command, privileged, the cgroup volume — is what makes a container behave enough like a machine for service management to work. Roles that start services need it; roles that only place files do not, and running unprivileged where you can is better.
pre_build_image: true uses the image as-is rather than building on top, which is faster.
verifier.name: ansible writes assertions as a playbook, which means no second language to learn and assertions that read like the rest of your repository. Testinfra remains an option and is more expressive for filesystem and process assertions, at the cost of Python in a repository that otherwise has none.
The test sequence
Section titled “The test sequence”molecule testRuns, in order: dependency, lint (if configured), cleanup, destroy, syntax, create, prepare, converge, idempotence, side_effect, verify, cleanup, destroy.
The two that catch the most:
Idempotence runs converge a second time and fails if any task reports changed. This is Ansible’s central promise, tested rather than assumed. A role failing here restarts services on every run — because a changed task notifies its handler — and cannot be safely re-run after a partial failure, which is exactly when you most need to re-run it.
Verify asserts the outcome:
- name: Verify hosts: all gather_facts: false tasks: - name: Check the config file exists with the right mode ansible.builtin.stat: path: /etc/app/app.conf register: conf
- name: Assert ansible.builtin.assert: that: - conf.stat.exists - conf.stat.mode == "0640" - conf.stat.pw_name == "app"
- name: Check the service is running ansible.builtin.service_facts:
- name: Assert the service is active ansible.builtin.assert: that: - ansible_facts.services["app.service"].state == "running"Assert the contract, not the implementation. That the file exists with the right ownership is the contract. That it was created by a particular task is not, and asserting it makes the role impossible to refactor.
Testing several platforms
Section titled “Testing several platforms”A role claiming to support three distributions and testing one supports one.
platforms: - name: debian image: docker.io/library/debian:13 - name: rocky image: docker.io/library/rockylinux:10 - name: ubuntu image: docker.io/library/ubuntu:26.04All platforms in one scenario converge together, which tests them in parallel and fails the scenario if any fails.
Or a scenario per platform, run as a CI matrix, which gives clearer failures and separate timing — a job named molecule (rocky) tells you which platform broke before you open anything.
The matrix approach scales better for a role supporting many platforms, because a failure names the platform in the job title rather than in the log.
Match meta/main.yml. The galaxy_info.platforms list is a claim about what the role supports; the scenario is what makes the claim true. A mismatch between them is a promise to consumers that nothing verifies, and it is the sort of thing that gets discovered by somebody else’s production run.
molecule: runs-on: ubuntu-latest strategy: fail-fast: false matrix: role: ${{ fromJSON(needs.detect.outputs.roles) }} steps: - uses: actions/checkout@v7
- uses: actions/setup-python@v7 with: python-version: "3.14"
- name: Install run: | python -m pip install --upgrade pip pip install "molecule" "molecule-plugins[docker]" "ansible-lint" "ansible-core"
- name: Test working-directory: roles/local/${{ matrix.role }} run: molecule testfail-fast: false so one role’s failure does not hide another’s.
molecule-plugins[docker] installs the Docker driver dependencies. The bundled playbooks handle create and destroy; the plugin provides what they need.
Pin the versions. A new Molecule or ansible-core changes behaviour without a commit of yours.
Give it a generous timeout. Scenarios are minutes, and a timeout tuned for lint kills them midway — leaving containers behind.
Scope to changed roles. Running every scenario on every pull request is slow enough that people work around it. Ansible CI covers the detection.
Containers are not machines
Section titled “Containers are not machines”The limits, because a green scenario is not proof.
No real init in a plain container. Service management needs the systemd configuration above, and even then it is not identical to a machine.
No kernel modules, no real hardware. Roles touching either cannot be tested this way.
Networking differs. A role configuring firewall rules or network interfaces will behave differently.
Filesystem layout may differ from a full installation of the same distribution — official images are minimal.
No existing state. A container is clean; a real host has years of accumulated configuration, a previous version of your application, and whatever somebody did to it during an incident in 2023. This is the gap that matters most — clean installs are the case that always works — and a prepare.yml scenario establishing a prior state is what partially closes it.
What to do about it: test what containers test well — package installation, file placement, templating, service enablement, users and permissions. That is the majority of what most roles do. For the rest, use a VM driver or accept that the first real run is the test, and do it against one development host with --limit.
Debugging a scenario
Section titled “Debugging a scenario”molecule converge applies the role and leaves the container running.
molecule login opens a shell in it, which is how you find out what actually happened.
molecule verify re-runs assertions against the running container without re-converging.
molecule destroy cleans up when you are done.
That loop — converge, login, inspect, fix, converge — is the productive one. molecule test destroys the container at the end, which is right for CI and unhelpful while debugging.
--destroy=never keeps the container after a failed molecule test, which is what you want when a CI failure does not reproduce locally.
profile_tasks in the provisioner configuration reports per-task timing, which finds the step making a scenario slow.
Scenario patterns worth having
Section titled “Scenario patterns worth having”default — the role applied to a clean target, verified.
upgrade — prepare.yml establishes a prior version’s state, then converge tests that the role handles an existing installation. This catches the failures that matter most, because a clean install is the case that always works.
idempotence is built in, not a scenario.
A failure scenario, where prepare.yml creates a broken state — a conflicting package, a wrong file mode — and the role is expected to correct it.
Scenario per platform, where the platforms differ enough that combined output is unreadable.
What not to build: a scenario per variable combination. That is a combinatorial explosion, and the two or three combinations that matter are the ones consumers actually use.
Keeping scenarios fast
Section titled “Keeping scenarios fast”Molecule is the slowest part of an Ansible pipeline, and slow enough tests get skipped.
Use pre-built images. pre_build_image: true with an image that already has Python and systemd avoids building one per run. Building a test image on every scenario is the commonest cause of a slow suite.
Cache the pip install. The Python dependencies are the same every run; caching them keyed on the requirements saves a minute per job.
Do not test what does not need testing. A role that writes one file does not need a systemd container. Drop the privileged configuration and it runs in seconds.
Split slow scenarios out. If an upgrade scenario takes four minutes and default takes forty seconds, run default on every pull request and upgrade on the default branch or nightly.
Parallelise across roles, not within a scenario. A matrix of roles uses several runners; platforms within one scenario converge sequentially by default.
Profile it. callbacks_enabled: profile_tasks reports per-task timing, and the answer is usually one package installation or one slow download rather than the role in general.
Watch for scenarios that are slow because they are wrong. A converge waiting sixty seconds for a service that never becomes ready is a failing test that has not failed yet.
The target worth aiming for: the full pull request suite under five minutes, with Molecule the bulk of it. Above ten, people start merging without waiting.
What to test and what not to
Section titled “What to test and what not to”Not every role needs the full treatment, and pretending otherwise means the important tests get less attention.
Always test: roles other teams consume, roles that manage services, roles that touch security-relevant configuration, and roles whose failure mode is subtle.
Usually test: anything with conditional logic across platforms, and anything that has broken before.
Rarely worth it: a role that copies one static file, a role that is a thin wrapper around a single well-tested module, and a role used once by one playbook that a person runs and watches.
The question to ask: if this role were broken, how would you find out? If the answer is “immediately, because I run it and watch”, a test adds less. If the answer is “when a host misbehaves next month”, a test is the difference.
Test coverage is not the goal. A role with a scenario asserting that a file exists, when the interesting behaviour is what happens on an upgrade, has coverage and no useful test. One good assertion about the case that actually breaks beats twenty about the case that always works.
Common mistakes
Section titled “Common mistakes”Assuming the old driver model. delegated is the default; Docker and Podman playbooks are bundled; other drivers come from molecule-plugins.
Leaving superseded driver packages installed. molecule-docker alongside molecule-plugins produces confusing errors.
Testing one platform while claiming several. The claim is a fiction.
No idempotence expectation. A role that changes something on every run restarts services unnecessarily.
Asserting implementation rather than contract. The role cannot be refactored.
privileged: true everywhere. A real privilege grant on the runner, needed only for service management.
Only a default scenario. Clean installs always work; upgrades are where roles break.
A CI timeout tuned for lint. Scenarios killed midway, containers left behind.
Treating a green scenario as proof it works on your hosts. It works on a clean container.
Running Molecule against a real inventory. It creates and destroys targets; pointing it at a real host means destroying one.
Drivers beyond containers
Section titled “Drivers beyond containers”Containers cover most cases and not all.
Podman works like Docker and is bundled the same way. Rootless Podman avoids the privileged-container concern to a degree, which matters on shared runners.
VM drivers — Vagrant, or a cloud driver — give a real kernel, real init and real networking. Slower by an order of magnitude, and the only option for a role touching kernel modules, storage or network interfaces.
The delegated default hands create and destroy to playbooks you write. That is the escape hatch: any target you can provision with Ansible can be a Molecule target, including a cloud instance or an existing lab machine.
Cloud drivers create real infrastructure, which costs money and needs credentials. If you use one:
A dedicated account containing nothing else. Credentials scoped so a failed destroy cannot reach anything real.
Guaranteed teardown — a destroy step that always runs, plus a scheduled sweep for the instances a crashed job left behind.
A hard cap on concurrent instances.
Never from pull request builds on a public repository. That is arbitrary code creating cloud resources with your credentials.
The practical recommendation: containers for the pull request suite, and a VM or cloud scenario nightly or on the default branch if you genuinely need one. Putting a slow, credentialed scenario in the pull request path is how it gets disabled.
Molecule and the rest of the pipeline
Section titled “Molecule and the rest of the pipeline”Where it fits, and what it does not replace.
After lint, before execution. Lint checks form in seconds; Molecule checks behaviour in minutes; a real run against a development host checks reality.
It does not replace a careful first run. A role that passes every scenario still meets your hosts for the first time when somebody runs it. --limit to one host, --check, --diff, and watch.
It does not test your playbooks, only your roles. A playbook applying five roles in an order that matters is not covered by five role scenarios.
It does not test your inventory. Groups, variables and targeting are outside its scope.
It complements lint rather than overlapping. Lint catches a shell with no changed_when; Molecule catches the role that does not actually work on Rocky Linux. Neither finds what the other does.
The realistic coverage picture: lint on everything, Molecule on the roles that matter, careful execution for the rest. A team with that is well covered, and a team with only one of the three has a specific gap it should be able to name.
Adopting Molecule on an existing repository
Section titled “Adopting Molecule on an existing repository”A repository of untested roles, and a decision to start testing.
-
Pick the role that has broken most often. Not the simplest one — the one whose failures you remember. That is where a test pays back first.
-
Write a
defaultscenario that only converges. No verify assertions yet. Getting the role to apply cleanly in a container is frequently harder than expected, and it is worth doing as its own step. -
Fix whatever it takes to converge. Usually a missing package assumption, or something the role expects the host to already have. Each of those is a real portability finding.
-
Add the idempotence expectation. This is where most existing roles fail, and each failure is a task that has been restarting services unnecessarily.
-
Add verify assertions for the outcomes that matter. Two or three, about the contract.
-
Add it to CI, and make it required for that role’s path.
-
Repeat for the next role, one at a time.
Do not attempt every role at once. Molecule adoption across twenty roles in one pull request is a week of yak-shaving and a red pipeline. One role, merged, working, then the next.
Expect step 3 to find real problems. A role that cannot converge in a clean container is a role with an undocumented dependency on how your hosts happen to be configured. That is worth knowing regardless of the test.
Expect step 4 to find more. Non-idempotent tasks are extremely common in roles that have never been tested for it, and every one of them is a service restarting on every run.
Mental model
Section titled “Mental model”Molecule is a harness that creates a target, applies the role, checks it twice and throws the target away. Its value is that the target is disposable, which is what lets it run on any pull request from anybody.
The corollary sets expectations: it proves the role works on something clean and simple. Your hosts are neither, and the gap is covered by a careful first run rather than by more tests.
What you learned
Section titled “What you learned”- The current model:
delegatedis the default driver, Docker and Podman playbooks are bundled, other drivers come frommolecule-plugins molecule testruns create, converge, idempotence, verify and destroy;convergeplusloginis the debugging loop- Idempotence testing is Ansible’s central promise, checked
- Verify assertions should test the contract, not the implementation
- Systemd in a container needs specific privileged configuration — use it only where the role manages services
- Test every platform you claim to support, ideally as a CI matrix
- An
upgradescenario withprepare.ymlcatches what clean-install testing never will - A container is not a machine: no kernel modules, different networking, no accumulated state
Exercise
Section titled “Exercise”Use a disposable repository with Docker available. No real hosts.
-
Create a role that installs a package and writes a templated config file. Add a
defaultMolecule scenario. -
Run
molecule test. Note the sequence of steps and the total time. -
Add a
commandtask with nochanged_when. Run again. Predict: which step fails? -
Fix it and confirm the idempotence step passes.
-
Add a verify assertion checking the file’s mode and owner. Change the mode in the role. Predict: does verify catch it?
-
Run
molecule converge, thenmolecule login, and inspect the container. Thenmolecule destroy. -
Add a second platform to
platforms. Predict: does the role work on both? -
Add an
upgradescenario whoseprepare.ymlwrites an older version of the config file. Predict: does the role handle an existing file correctly? -
Delete the repository and any remaining containers.
Related lessons
Section titled “Related lessons”The GitOps and infrastructure repository templates are in the Professional Toolkit.