Linting, Molecule and Meaningful Verification

Scenario: A role passed review and worked in staging. In production it wrote a config file successfully and the service never started, because the rendered file was syntactically invalid for that OS version.

New words, in plain English

Testing Ansible has three layers, and each catches a different class of problem.

Static analysis runs in seconds and needs no infrastructure. ansible-lint catches shell where a module exists, command without changed_when, unnamed tasks, deprecated syntax, unsafe file permissions and much more. --syntax-check catches structural YAML errors. Both belong on every pull request; they replace a lot of review comments that a machine can make instead of a person.

Molecule runs the role against a real, disposable environment. Its stages are fixed: create builds the environment, converge applies the role, idempotence runs it again and fails on any change, verify runs your assertions, destroy cleans up. molecule test runs the whole sequence.

Verification is where the value concentrates and where most teams stop short. Asserting that a file exists proves the task ran. Asserting that the service is listening on the expected port, that its own parser accepts the config, and that a health endpoint answers proves the role worked. The scenario is exactly this gap: the file existed, and nothing checked that the service could use it.

Molecule scenarios can also test multiple platforms - a Debian and a RedHat container in the same run - which is the practical way to keep multi-OS roles honest.

Analogy: Lint is spell-check. Converge is printing the document. Idempotence is checking that printing it twice does not produce two documents. Verify is reading it to see whether it says anything true - and it is the step everyone skips.

A worked example

# Static - fast, on every pull request
ansible-lint
ansible-playbook site.yml --syntax-check

# molecule/default/molecule.yml
---
driver:
  name: docker
platforms:
  - name: ubuntu
    image: ubuntu:22.04
  - name: rocky
    image: rockylinux:9
provisioner:
  name: ansible
verifier:
  name: ansible

# molecule/default/verify.yml - assert BEHAVIOUR, not file existence
- name: Verify
  hosts: all
  tasks:
    - name: The service is listening
      ansible.builtin.wait_for:
        port: 8080
        timeout: 10

    - name: Its own parser accepts the rendered config
      ansible.builtin.command: nginx -t
      changed_when: false

    - name: The health endpoint answers
      ansible.builtin.uri:
        url: http://localhost:8080/healthz
        status_code: 200

# molecule test  ->  create, converge, idempotence, verify, destroy

ansible-lint is configurable through .ansible-lint, and it is worth tuning rather than either ignoring it or accepting every rule unexamined. Some rules are strong safety signals; a few are stylistic. Agree the set once, put it in the repository, and let CI enforce it consistently.

This is the same philosophy the labs in this track use. Their checkers do not confirm that you wrote a file - they run your playbook, run it again to prove idempotency, and then inspect the real end state on the managed hosts over SSH. Passing requires the automation to actually work, not merely to run.

Tip: Write the verify stage first, before the role. It forces you to state what "working" means before you start guessing at tasks.
Goal: Put this to work in the ansible-check-mode-safety lab. Open /labs/ansible, pick ansible-check-mode-safety, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.