Testing & linting your automation
A playbook that runs against your whole fleet deserves the same care as any other code. Before it goes near production you want to know: does it parse? Does it follow good habits? Does a second run change nothing? Does the server actually work afterwards? This lesson is the toolbox for those four questions, and ends with the checks you'd put in a CI pipeline.
You will learn
--syntax-check,--check --diff,--list-tasks- ansible-lint: the rules behind its messages, and profiles
- The idempotence test: run twice, expect
changed=0 - Smoke tests with
uri+assert - Tags: run only part of a playbook
- Molecule, and Ansible checks in CI
Four levels of checking
| Check | Command | Catches |
|---|---|---|
| Syntax | ansible-playbook site.yml --syntax-check | YAML mistakes, unknown modules, broken structure. Seconds, no servers needed. |
| Lint | ansible-lint | Risky or sloppy patterns: no mode on files, commands that always say changed, state: latest, unnamed tasks |
| Idempotence | run the playbook twice | Tasks that change things every time. The second run must say changed=0. |
| Smoke test | a test playbook with uri + assert | “It ran fine, but the site is down” |
ansible-lint
sudo dnf install epel-release sudo dnf install ansible-lint
sudo apt install ansible-lint
Either way you can also get the newest version with pipx install ansible-lint. Run it in the project folder:
WARNING Listing 7 violation(s) that are fatal fqcn[action-core]: Use FQCN for builtin module actions (package). roles/web/tasks/main.yml:2 Use `ansible.builtin.package` or `ansible.legacy.package` instead. … Failed: 7 failure(s), 0 warning(s) on 8 files. Last profile that met the validation criteria was 'min'.
| Rule | What it means | Fix |
|---|---|---|
fqcn[action-core] | short module name | package: → ansible.builtin.package: |
name[missing] / name[casing] | unnamed task / lowercase name | give every task a Capitalised name |
yaml[truthy] | yes/no | use true/false |
risky-file-permissions | template/copy without mode | mode: "0644" (quoted!) |
package-latest | state: latest upgrades whenever the playbook runs | state: present, and upgrade on purpose |
no-changed-when | a command that always reports changed | changed_when:, creates:, or a real module |
Profiles are levels, from min to production. The last line tells you the highest level your code fully meets, which is handy for setting a goal: “this repo must pass production”.
The idempotence test
Run the playbook twice in a row. The second recap must be all changed=0. If a task keeps changing (like shell: date > deployed.txt), it's either doing needless work or hiding a real difference between runs. Either way, fix it or delete it, because a playbook that always “changes” makes real changes impossible to spot.
Smoke tests
- name: Smoke-test the web servers
hosts: web
gather_facts: false
tasks:
- name: Fetch the home page from the control machine
ansible.builtin.uri:
url: "http://{{ inventory_hostname }}/"
return_content: true
delegate_to: localhost
register: page
- name: The page has the right title
ansible.builtin.assert:
that:
- page.status == 200
- "site_title in page.content"
Tags: just the part you need
- name: Put our home page in place ansible.builtin.template: … tags: content ansible-playbook site.yml --list-tasks # what would run, with tags ansible-playbook site.yml --tags content # only tasks tagged content ansible-playbook site.yml --skip-tags content
Handy for a quick content update, but always run the full playbook regularly too, so nothing drifts.
In CI and beyond
Put the cheap checks in your CI pipeline (from the DevOps path) so every push is checked:
steps: - uses: actions/checkout@v4 - run: pip install ansible-core ansible-lint - run: ansible-playbook site.yml --syntax-check - run: ansible-lint
The next step up is Molecule: it starts throw-away containers (Rocky and Ubuntu, if you like), applies your role, runs it again to check idempotence, and runs tests against the result. It's the standard way to test roles you share.
Practice: make it clean, make it idempotent 🧪
A teammate wrote the web role in a hurry. Lint it, fix what it finds, prove it's idempotent, then run the smoke tests. They'll catch a problem lint can't.
Quick check
1. The second run of a playbook reports changed=1 on every server. What does that tell you?
✓ Find it with -v or --diff, then fix it or delete it.
2. ansible-lint passes and the playbook ran fine. Can the site still be broken?
✓ Lint checks the code, and smoke tests check the result.
3. Why is state: latest flagged?
✓ Predictable beats surprising.
Next up: AI Basics · Use AI wisely, starting with “AI as your Linux sidekick”.