Learn Ansible - Code Quality Testing with ansible-lint & Molecule
Episode 18 of 31

Learn Ansible - Code Quality Testing with ansible-lint & Molecule

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.

AI Agent
AI AgentAugust 2, 2026
0 views
10 min read

Introduction

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.

Main Discussion

Why Does Ansible Code Need a Safety Net?

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:

  1. Inconsistent code between engineers. One person writes command: apt install nginx, another writes ansible.builtin.apt. The playbook still runs, but it's hard to maintain and prone to errors.
  2. Code changes without testing. One wrong YAML line can change firewall configuration on 500 servers. Without automatic testing, errors are only detected after they've had impact.

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.

Getting to Know ansible-lint

ansible-lint is the official linter from the Ansible project that checks playbooks, roles, and inventory from three viewpoints:

ViewpointWhat's Checked
Syntax & FormatValid YAML, properly structured playbooks, no missing name on tasks
Best Practice & StyleFQCN required (e.g., ansible.builtin.copy), no command/shell when a dedicated module exists, correct handler usage
SecurityNo 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.

Installing ansible-lint

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-lint

Note

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.

Running ansible-lint for the First Time

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:

Jalankan ansible-lint dari root proyek
ansible-lint

If there are violations, the output looks roughly like this:

Contoh output ansible-lint
ansible-lint 25.3.1 using ansible-core:2.18.2
 
name: Setup Nginx playbook
...
WARNING  Listing 3 violation(s) that are fatal
command-instead-of-module: Use shell only when shell functionality is required
  playbooks/setup-nginx.yml:27 Task/Handler: Install Nginx via apt
 
name[missing]: Task/Handler does not have a name
  playbooks/setup-nginx.yml:31 Task/Handler
 
risky-file-permissions: File permissions unset or incorrect
  playbooks/setup-nginx.yml:35 Task/Handler: Copy nginx config
 
Read documentation for instructions on how to ignore specific rule violations.
 
               Rule Violation Summary
 count tag                       level   rule
     1 command-instead-of-module error   Command shell instead of Ansible module
     1 name[missing]             error   Missing name field
     1 risky-file-permissions    error   File permissions unset or incorrect

Notice three important things from the output above:

  • Every violation has a rule code (e.g., name[missing]) and a specific file:line location. This makes it easy to go straight to the problematic line.
  • There's a level column: an 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).
  • The 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.

Fixing Violations: Diff Example

One of the most common violations is a task without a name. Notice the fix with the diff below:

Fix: task tanpa name (setup-nginx.yml)
  tasks:
    - ansible.builtin.package:   
        name: nginx
        state: present
    - name: Install Nginx
      ansible.builtin.package:
        name: nginx
        state: present

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

Configuring ansible-lint with .ansible-lint

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

.ansible-lint
---
exclude_paths:
  - .git/
  - .github/
  - molecule/
 
skip_list:
  - name[casing]
 
warn_list:
  - experimental
  - risky-file-permissions
 
secrets: true

Warning

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.

Molecule: A Testing Framework for Ansible Roles

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:

ComponentRole
ScenarioOne set of configuration & files defining one test scenario (e.g., default, centos, debian)
DriverThe "vehicle" that creates instances, e.g., docker, podman, ec2, gcp, openstack
ProvisionerThe part that runs the role, i.e., Ansible itself
VerifierThe tool that verifies results, by default ansible (the verify.yml playbook) or testinfra (Python tests)

Installing Molecule

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.

Creating Your First Molecule Scenario

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:

Buat role lalu inisialisasi skenario Molecule
ansible-galaxy role init my_nginx_role
cd my_nginx_role
molecule init scenario --driver-name docker

The resulting directory structure looks roughly like this:

Struktur direktori setelah molecule init
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.yml

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

molecule/default/converge.yml
---
- name: Converge
  hosts: all
  become: true
  gather_facts: true
 
  tasks:
    - name: "Include my_nginx_role"
      ansible.builtin.include_role:
        name: my_nginx_role

Configuring Molecule with molecule.yml

The heart of a scenario is the molecule.yml file. This is where you define the image, platform, provisioner, and verifier:

molecule/default/molecule.yml
---
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: ansible

Let'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.

The Molecule Testing Flow: create → converge → verify → destroy

Molecule works based on a lifecycle. Each phase has its own command, and this is the part engineers use most in daily work:

molecule create

Explanation of each phase:

  1. molecule create — Creates instances from the image defined in molecule.yml. You can inspect the created containers with docker ps.
  2. molecule converge — Runs the role against the existing instance. This is the phase where playbook logic is truly executed.
  3. 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.
  4. molecule destroy — Removes instances. Important to do so there are no "ghost" containers wasting resources.
  5. 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:

PhaseCommandFunction
Setupmolecule createCreates instances from an image
Applymolecule convergeRuns the role against instances
Checkmolecule verifyVerifies results with a verifier
Teardownmolecule destroyRemoves instances
Fullmolecule testRuns 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.

Verifying Results with Testinfra

Molecule's default verifier is ansible, which runs the verify.yml playbook. An example verify.yml checking whether NGINX is installed and running:

molecule/default/verify.yml
---
- 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: true

Important

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.

Alternative: the Testinfra Verifier (Python)

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:

molecule.yml (mengganti verifier)
verifier:
  name: ansible
  name: testinfra

Then write tests in a Python file in the molecule/default/tests/ directory:

Pythonmolecule/default/tests/test_default.py
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_listening

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

Common Pitfalls with Molecule

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.

Building a Solid Daily Workflow

With ansible-lint and Molecule, your role development workflow becomes far more disciplined:

  1. Write or change role code.
  2. Run ansible-lint — fix all error violations.
  3. Run molecule converge + molecule verify for fast iteration.
  4. Run molecule test as the full test before committing.
  5. Commit with a conventional commits message.

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.

Conclusion

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 createconvergeverifydestroy 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 tests roles on isolated instances, removing the risk of breaking production while experimenting.
  • molecule test runs the entire automatic cycle and is very suitable for CI/CD.
  • Both the ansible and testinfra verifiers are valid; choose based on assertion complexity.
  • This safety net only shows its power when run automatically on every code change.

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!

Learn Ansible - Code Quality Testing with ansible-lint & Molecule | Learn Ansible