Standardize Ansible code quality with ansible-lint and automate role testing in isolation using Molecule, from the Docker/Podman driver to automatic verification with Testinfra.

After episode 17, where we covered writing Custom Modules and Custom Filters with Python, you now know that Ansible code can be extended as needed. However, there's an important consequence of that ability: the more code we write ourselves, the greater our responsibility to maintain its quality.
Imagine this scenario: you're working on a playbook tasked with cleaning up old logs on hundreds of production servers. The playbook passes testing on your local machine, then runs in production and turns out to delete the wrong directory. The impact isn't just a bad appearance on screen, but down services, lost data, and an incident report you have to write. This is why infrastructure code needs a safety net just as strict as application code.
In episode 18, we'll cover two tools that are the backbone of Ansible code quality in the real world: ansible-lint for enforcing best practices, writing style, and security; and Molecule for automatically testing roles in an isolated environment using Docker/Podman. After this episode, you'll understand the workflow expected in professional teams: lint first, test first, then deploy.
In previous episodes, we covered idempotency, handlers, roles, and collections. All of those are foundations so automation is safe. But foundations alone aren't enough. A playbook could be idempotent yet still dangerous: for example, writing hardcoded passwords into the code, using shell for things that should use a dedicated module, or running tasks without a name so execution logs are hard to read during an incident.
Think of it this way: idempotency is like a car that never breaks down. But you still need traffic signs so you don't crash (ansible-lint), and you need a driving simulator so you can practice without damaging real roads (Molecule). Both are part of a healthy engineering culture: writing quality into the process, not just into intention.
Two real problems most often encountered in the field:
command: apt install nginx, another writes ansible.builtin.apt. The playbook still runs, but it's hard to maintain and prone to errors.ansible-lint and Molecule answer these two problems in sequence: the former ensures the code is correct and consistent statically, the latter ensures the code actually works in a real environment.
ansible-lint is the official linter from the Ansible project that checks playbooks, roles, and inventory from three viewpoints:
| Viewpoint | What's Checked |
|---|---|
| Syntax & Format | Valid YAML, properly structured playbooks, no missing name on tasks |
| Best Practice & Style | FQCN required (e.g., ansible.builtin.copy), no command/shell when a dedicated module exists, correct handler usage |
| Security | No hardcoded passwords/API keys, explicit file permissions (mode), no sensitive content missing no_log |
What distinguishes ansible-lint from just a YAML parser is that it understands Ansible semantics. It knows that ansible.builtin.apt is more appropriate than shell: apt-get install, and it can detect dangerous patterns that are syntactically valid.
The recommended installation method is using pipx so it's isolated from the system Python environment, exactly like the way we installed Ansible in episode 0:
pipx install ansible-lintNote
ansible-lint needs ansible-core as a dependency. If you install it via pipx, the matching ansible-core version is automatically installed with it. Make sure the version is compatible with the playbooks you write.
The easiest way is running it from the Ansible project's root directory. ansible-lint automatically looks for playbooks, roles, and the ansible.cfg file around it:
ansible-lintIf there are violations, the output looks roughly like this:
Notice three important things from the output above:
name[missing]) and a specific file:line location. This makes it easy to go straight to the problematic line.error violation makes the command stop with a non-zero exit code, which is very useful when installed in a CI/CD pipeline (we'll cover that in episode 19).command-instead-of-module rule appears because the playbook author used shell even though there's an ansible.builtin.apt module. This is a classic example of "correct syntactically, wrong in best practice".Tip
Run ansible-lint from the project root, not from a subdirectory, so it reads your ansible.cfg and lint config files. This also ensures all roles and collections get consistently checked.
One of the most common violations is a task without a name. Notice the fix with the diff below:
tasks:
- ansible.builtin.package:
name: nginx
state: present
- name: Install Nginx
ansible.builtin.package:
name: nginx
state: presentBy adding a descriptive name, the playbook output becomes easy to read during debugging and ansible-lint no longer complains. Small things like this make a big difference when reading thousands of execution log lines in the middle of an incident.
.ansible-lintNot all teams apply the same rules. The .ansible-lint configuration file (YAML) lets you exclude paths, skip certain rules, or downgrade a rule to just a warning:
---
exclude_paths:
- .git/
- .github/
- molecule/
skip_list:
- name[casing]
warn_list:
- experimental
- risky-file-permissions
secrets: trueWarning
Be careful with skip_list. Skipping rules is doable, but it also means you consciously accept the risk that rule protects against. As good practice, write a comment explaining the reason in the config file each time you disable a rule, so other engineers (and your future self) understand why.
After your code passes lint, the next question is: does this code actually work? This is where Molecule comes in.
Molecule is a testing framework for Ansible roles. It works in a very sensible way: builds target instances in an isolated environment (e.g., Docker or Podman containers, or cloud VMs), then runs your role there, verifies the results, and cleans up all its traces.
Why is this important? Because testing a role directly on production servers is like testing a parachute when you're already above the plane. Molecule lets you do "parachute tests" thousands of times in a lab without the slightest risk to the running infrastructure.
Molecule's architecture consists of several components:
| Component | Role |
|---|---|
| Scenario | One set of configuration & files defining one test scenario (e.g., default, centos, debian) |
| Driver | The "vehicle" that creates instances, e.g., docker, podman, ec2, gcp, openstack |
| Provisioner | The part that runs the role, i.e., Ansible itself |
| Verifier | The tool that verifies results, by default ansible (the verify.yml playbook) or testinfra (Python tests) |
Molecule is installed separately from Ansible, along with the driver you want to use:
pipx install "molecule[docker]"Important
You must install the driver according to the container runtime available on your machine. The docker driver needs a running Docker daemon, while the podman driver needs Podman (e.g., on Fedora/RHEL or WSL2). It's fine to install both, but remember that Molecule picks the driver based on the configuration in molecule.yml.
Molecule works together with Ansible roles. First, create a role using ansible-galaxy role init (which we already covered in episode 12), then initialize a Molecule scenario inside it:
ansible-galaxy role init my_nginx_role
cd my_nginx_role
molecule init scenario --driver-name dockerThe resulting directory structure looks roughly like this:
my_nginx_role/
├── molecule/
│ └── default/
│ ├── converge.yml # Playbook untuk menjalankan role
│ ├── create.yml # Membuat instance (di-generate driver)
│ ├── destroy.yml # Menghapus instance (di-generate driver)
│ ├── molecule.yml # Konfigurasi utama skenario
│ └── verify.yml # Playbook verifikasi (verifier ansible)
├── defaults/
│ └── main.yml
├── tasks/
│ └── main.yml
└── meta/
└── main.ymlThe converge.yml file is the playbook that "injects" your role into the test instance. It's similar to a regular playbook, except the role is called directly:
---
- name: Converge
hosts: all
become: true
gather_facts: true
tasks:
- name: "Include my_nginx_role"
ansible.builtin.include_role:
name: my_nginx_rolemolecule.ymlThe heart of a scenario is the molecule.yml file. This is where you define the image, platform, provisioner, and verifier:
---
dependency:
name: galaxy
driver:
name: docker
platforms:
- name: nginx-ubuntu-2204
image: geerlingguy/docker-ubuntu2204-ansible:latest
pre_build_image: true
privileged: true
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
provisioner:
name: ansible
playbooks:
converge: converge.yml
verifier:
name: ansibleLet's break down the important parts:
platforms defines the instances to create. You can define several platforms at once, e.g., one Ubuntu and one Debian, to ensure the role works on many operating systems.image sets the base container image. Images from geerlingguy already contain Ansible and Python, making testing faster.privileged: true and the cgroup volume are needed if your role runs services with systemd inside the container.Note
If you're testing a fairly lightweight role (without systemd), you can use a regular image like ubuntu:22.04 with pre_build_image: true without privileged. Rule of thumb: need systemd → use a systemd image + privileged; don't need systemd → a regular container is enough.
Molecule works based on a lifecycle. Each phase has its own command, and this is the part engineers use most in daily work:
molecule createExplanation of each phase:
molecule create — Creates instances from the image defined in molecule.yml. You can inspect the created containers with docker ps.molecule converge — Runs the role against the existing instance. This is the phase where playbook logic is truly executed.molecule verify — Runs the verifier to ensure results match expectations. Here you can check whether the service is running, packages are installed, ports are open, and so on.molecule destroy — Removes instances. Important to do so there are no "ghost" containers wasting resources.molecule test — Runs the entire cycle automatically: create → converge → verify → destroy. This is the command usually installed in CI/CD pipelines.Tip
In day-to-day debugging, don't immediately use molecule test. It's more efficient to run molecule create then molecule converge repeatedly while fixing code, because the instance stays alive and repeated testing becomes very fast. When done, run molecule destroy to clean up.
Molecule lifecycle summary in a table:
| Phase | Command | Function |
|---|---|---|
| Setup | molecule create | Creates instances from an image |
| Apply | molecule converge | Runs the role against instances |
| Check | molecule verify | Verifies results with a verifier |
| Teardown | molecule destroy | Removes instances |
| Full | molecule test | Runs the entire cycle at once |
One of Molecule's greatest strengths is the idempotency check. Run molecule converge twice in a row: on the second run, all tasks must report ok (not changed). If a task changes on the second run, your role isn't idempotent yet — exactly the principle we learned in episode 5, but this time tested automatically.
Molecule's default verifier is ansible, which runs the verify.yml playbook. An example verify.yml checking whether NGINX is installed and running:
---
- name: Verify
hosts: all
become: true
gather_facts: false
tasks:
- name: Nginx terinstall
ansible.builtin.package:
name: nginx
state: present
check_mode: true
- name: Service nginx aktif dan enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
check_mode: trueImportant
Notice check_mode: true on the verification tasks. This is an important pattern in verify.yml: we only want to check, not change anything. With check_mode, tasks only report whether the condition is already correct without executing any changes.
Besides the Ansible verifier, Molecule also supports Testinfra, a Python testing framework that writes assertions as very expressive Python functions. To enable it, change the verifier section in molecule.yml:
verifier:
name: ansible
name: testinfraThen write tests in a Python file in the molecule/default/tests/ directory:
import testinfra.utils.ansible_runner
testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner.get_hosts(
"all"
)
def test_nginx_is_installed(host):
nginx = host.package("nginx")
assert nginx.is_installed
def test_nginx_service_running_and_enabled(host):
service = host.service("nginx")
assert service.is_running
assert service.is_enabled
def test_nginx_listening_on_port_80(host):
socket = host.socket("tcp://0.0.0.0:80")
assert socket.is_listeningTestinfra is more popular with teams comfortable writing assertions in Python, because its flexibility is much higher: you can do data manipulation, loops, and complex logic without being constrained by YAML syntax. This also aligns with the Python skills you learned in episode 17.
Tip
Both the ansible and testinfra verifiers have their place. For simple assertions, verify.yml is more concise. For complex or repetitive assertions (e.g., validating a list of 50 packages), Testinfra is far more comfortable. Many senior teams even use both in different scenarios.
Here are the traps most often encountered when first using Molecule:
1. Containers without systemd. Standard container images don't run an init system. If your role calls ansible.builtin.systemd_service with state: started, the task can fail or have no effect. The solution: use an image that provides systemd (like geerlingguy/docker-ubuntu2204-ansible) with privileged: true and the cgroup volume.
2. Forgetting molecule destroy. Uncleaned instances pile up and eat disk/RAM. Get used to molecule test, which automatically removes instances, or run molecule destroy explicitly.
3. Ansible version differences between local and CI. A role that passes on your machine can fail in CI because of a different ansible-core version. The real solution will be covered in episodes 19 and 20, but from now on get used to pinning dependency versions.
4. A verifier that checks nothing. An empty verify.yml or overly loose assertions gives false confidence. Make sure every role feature has a matching assertion.
5. Running molecule test in the wrong directory. Molecule commands must be run from inside the role directory (where the molecule/ folder is), not from the project root.
With ansible-lint and Molecule, your role development workflow becomes far more disciplined:
ansible-lint — fix all error violations.molecule converge + molecule verify for fast iteration.molecule test as the full test before committing.This flow can be fully automated with pre-commit for the lint step, and with a CI/CD pipeline for the test step — exactly what we'll cover in episode 19.
In episode 18, we learned that Ansible code quality isn't something that can be left to chance. With ansible-lint, you can enforce best practices, writing style, and security automatically — from FQCN and named tasks to detecting hardcoded passwords. With Molecule, you can test roles in an isolated environment using Docker/Podman, following the create → converge → verify → destroy flow, and verify results using Ansible playbooks or Testinfra Python tests.
Key points to take home:
ansible-lint statically checks syntax, style, and security; it must pass before code ships.molecule test runs the entire automatic cycle and is very suitable for CI/CD.ansible and testinfra verifiers are valid; choose based on assertion complexity.In episode 19, we'll take both tools to the next level with the topic CI/CD Pipeline Integration & IaC Paradigm. We'll create GitHub Actions and GitLab CI pipelines that automatically run linting, testing, and deployment, and learn how Ansible collaborates with Terraform in the provisioning and configuration management flow. Keep your enthusiasm up!