Skip to main content
RunBook Academy

AnsibleXXVI · Testing AutomationTesting automation

The layered testing model: what each rung actually proves

Intermediate⏱ ~24 minansible-playbookansible-lintyamllint

What you'll learn

  • Explain what a --syntax-check pass does and does not establish about a playbook
  • Read an ansible-lint rule for its intent rather than silencing it
  • Select an ansible-lint profile deliberately and know what changing it does to the rule set
  • Place a given defect on the lowest rung of the ladder that can catch it
  • State the defect classes that no pre-production layer can reach

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

Testing automation is not one activity. It is a ladder of layers, each of which is cheap relative to the one above it and blind to something the one above it can see.

Most teams build one or two rungs, then reason as though they had built the ladder. That is the failure this part exists to prevent, and the place it goes wrong first is the very bottom, where a command that reports success is routinely mistaken for a test.

The ladder

#LayerTypical cost per runWhat it can catchWhat it structurally cannot
1--syntax-checkunder a secondthe file is not loadable as a playbookanything about what the tasks do
2yamllintunder a secondtext-level YAML defects: duplicate keys, tabs, truthy valuesanything requiring knowledge of Ansible
3ansible-lintsecondsrisky and non-idempotent patterns, deprecations, naming, FQCNwhether the values are correct
4schema and argument validationsecondsa task that names an option the module does not havewhether the option value is right for this host
5disposable integration (Molecule)minutesthe role converges, is idempotent, and reaches its declared outcomeanything the container does not model
6stagingtens of minutesintegration with real services, real init system, real networkanything about production data and load
7canary in productionone host’s worth of riskeverything, on one hostnothing — and that is the point

Rungs 1 to 4 are static: nothing is executed against a host. Rungs 5 to 7 are dynamic, and each one buys fidelity by giving up speed.

The reason to know the ladder as a ladder is triage. When a defect reaches production, the question afterwards is not “should we test more?” — it is which is the lowest rung that could have caught this, and why did it not run there? That question has an answer. “We should have tested more” does not.

Rung 1 is almost empty, and this is the surprise

ansible-playbook --syntax-check is the first thing everyone reaches for, and its name flatters it. Here is a playbook with two defects that would fail on the first host it touched:

Read-only / Safebroken.yml
---
- name: Deploy the web tier
hosts: web
gather_facts: false
tasks:
  - name: Install the package
    ansible.builtin.package:
      name: nginx
      state: presnt

  - name: Write the config
    ansible.builtin.copy:
      src: nginx.conf
      dest: /etc/nginx/nginx.conf
      onwer: root
Read-only / Safethe syntax check is happy
$ ansible-playbook -i inventory.ini --syntax-check broken.yml
playbook: broken.yml

That is the entire output, and the exit code is 0.

--syntax-check loads the playbook, parses the YAML, and confirms that the resulting structure is a playbook: plays with hosts, task lists that are lists, keywords that exist. It does not import modules and it does not validate their arguments, because argument validation is something the module itself performs, on the target, at execution time.

So a green --syntax-check means this file can be loaded. It is worth running — an unloadable playbook that reaches CI wastes an agent slot — but it is not a test of the automation. Treating it as one is how a pipeline ends up with a green badge and no coverage.

Rung 3: ansible-lint reads Ansible, not YAML

ansible-lint is the first rung that knows what a task is. The course targets ansible-lint 26.x; the output below was produced by 26.6.0 running against ansible-core 2.21.3.

Here is a role with the defects people actually write:

Read-only / Saferoles/webserver/tasks/main.yml
---
- name: install nginx
package:
  name: nginx
  state: latest

- name: Deploy config
ansible.builtin.copy:
  src: nginx.conf
  dest: /etc/nginx/nginx.conf
  mode: 644

- ansible.builtin.shell: cat /etc/passwd | grep -c bash

- name: Reload nginx
ansible.builtin.command: systemctl reload nginx
Read-only / Safeansible-lint 26.6.0
$ ansible-lint --offline --show-relpath -f full .
WARNING  Listing 9 violation(s) that are fatal

# Rule Violation Summary

1 command-instead-of-module profile:basic tags:command-shell,idiom
1 name profile:basic tags:idiom
1 name profile:basic tags:idiom
1 package-latest profile:basic tags:idempotency
1 risky-octal profile:basic tags:formatting
1 risky-shell-pipe profile:basic tags:command-shell
2 no-changed-when profile:basic tags:command-shell,idempotency
1 fqcn profile:basic tags:formatting

Failed: 9 failure(s), 0 warning(s) in 3 files processed of 4 encountered.
Last profile that met the validation criteria was 'min'.

name[casing]: All names should start with an uppercase letter.
roles/webserver/tasks/main.yml:2:9 Task/Handler: install nginx

package-latest: Package installs should not use latest.
roles/webserver/tasks/main.yml:2 Task/Handler: install nginx

fqcn[action-core]: Use FQCN for builtin module actions (package).
roles/webserver/tasks/main.yml:3:3 Use `ansible.builtin.package` or `ansible.legacy.package` instead.

risky-octal: `mode: 644` should have a string value with leading zero `mode: "01204"` or use symbolic mode.
roles/webserver/tasks/main.yml:7 Task/Handler: Deploy config

name[missing]: All tasks should be named.
roles/webserver/tasks/main.yml:13 Task/Handler: shell cat /etc/passwd | grep -c bash

no-changed-when: Commands should not change things if nothing needs doing.
roles/webserver/tasks/main.yml:13 Task/Handler: shell cat /etc/passwd | grep -c bash

risky-shell-pipe: Shells that use pipes should set the pipefail option.
roles/webserver/tasks/main.yml:13 Task/Handler: shell cat /etc/passwd | grep -c bash

command-instead-of-module: systemctl used in place of systemd module
roles/webserver/tasks/main.yml:15 Task/Handler: Reload nginx

no-changed-when: Commands should not change things if nothing needs doing.
roles/webserver/tasks/main.yml:15 Task/Handler: Reload nginx

Every one of those has an operational reason behind it. Reading them as a style checklist is how a team ends up with a skip_list and no benefit.

risky-octal is the one worth reading twice. The message says mode: 644 should be mode: "01204". That is not a typo in the linter. YAML parsed the unquoted 644 as the decimal integer six hundred and forty-four, and Ansible will apply it as a mode — which in octal is 01204, the setuid bit plus permissions nobody intended. The linter is telling you what the file currently means, and it is not what it looks like.

package-latest is tagged idempotency. state: latest means the task’s result depends on what a mirror published this morning, so the same code produces ok today and changed tomorrow with no commit in between. That destroys the second-run signal covered in the next lesson, and it makes a rollback ambiguous — you cannot roll back to “latest”.

no-changed-when appears twice, on both the shell and the command task. Those modules cannot know whether they changed anything, so they report changed unconditionally. Two unfenced commands are a permanent noise floor of two on every host, forever.

risky-shell-pipe is about set -o pipefail. Without it, the exit status of cat /etc/passwd | grep -c bash is grep’s alone. A failure in the first command of the pipeline is silently discarded and the task reports success.

fqcn[action-core] and name[casing] / name[missing] look cosmetic and are not. FQCN is about which module actually resolves on the controller that runs the change, which can differ between your laptop and the CI runner. Task names are what the recap, the callback output and the audit log contain: an unnamed task appears in the log as its own command line, so an incident review reads shell cat /etc/passwd | grep -c bash instead of the reason anybody ran it.

Profiles are the dial, and turning it changes the rule set

ansible-lint ships six named profiles, each extending the one before:

ProfileAddsReasonable for
minonly the rules that prevent fatal load errorsa repository you have just inherited
basicnaming, FQCN, deprecations, schema, yamlthe default working target
moderatename[template], name[casing], readability rulesa repo with more than one author
safetylatest, package-latest, risky-*anything that touches production
sharedgalaxy, meta-*, no-changed-when, no-handlerroles other teams consume
productionfqcn, sanity, AAP certification rulespublished or certified content

The important mechanic: selecting a lower profile removes rules from the run rather than downgrading them. With --profile min the same role above reports nothing at all:

Read-only / Safethe same nine defects, at --profile min
$ ansible-lint --offline --profile min -f brief .
Passed: 0 failure(s), 0 warning(s) in 3 files processed of 4 encountered. Profile 'min' was required, but 'production' profile passed.

Read that second sentence carefully, because it is the trap. Nothing about the role changed. ansible-lint evaluated only the rules in the min profile, none of them failed, and it then reported the highest profile satisfied by the rules it actually ran — which is production. A role with nine fatal violations at the default profile has just produced a line containing the word “production” next to the word “passed”.

Lowering the profile is a legitimate thing to do on day one with a large legacy repository: a gate that fails on nine thousand violations is a gate everybody disables. It is not a legitimate thing to leave in place without a plan to raise it, and the plan should be in the repository, not in someone’s head.

Rung 4: the gap the static layers leave open

Return to the playbook with state: presnt and onwer: root. The syntax check passed it. What does the linter say?

Read-only / Safeansible-lint on the same broken playbook
$ ansible-lint --offline --show-relpath -f brief broken.yml
WARNING  Listing 1 violation(s) that are fatal

# Rule Violation Summary

1 risky-file-permissions profile:safety tags:unpredictability

Failed: 1 failure(s), 0 warning(s) in 1 files processed of 1 encountered.
Last profile that met the validation criteria was 'moderate'. Rating: 2/5 star

risky-file-permissions: File permissions unset or incorrect.
broken.yml:11 Task/Handler: Write the config

It found a real defect — the copy task sets no mode, so the file’s permissions depend on the remote umask — and it said nothing about either misspelling. Module argument validation lives in ansible-lint’s args rule, which is tagged experimental and therefore in the default warn list rather than the failing set.

The two defects are caught at the moment the module runs, and not before:

Read-only / Safethe run finds it immediately
$ ansible-playbook typo.yml
TASK [Stat a file with a misspelled option] ************************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "Unsupported parameters for (ansible.builtin.stat) module: folow. Supported parameters include: checksum_algorithm, follow, get_attributes, get_checksum, get_mime, get_selinux_context, path (attr, attributes, checksum, checksum_algo, dest, mime, mime-type, mime_type, name)."}

PLAY RECAP *********************************************************************
localhost                  : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

The error message is excellent. The problem is where it arrives: on a host, mid-run, after the play has already made whatever changes came before it. On a fleet with serial: 25, that is twenty-five hosts into a change nobody wanted to start.

This is the argument for rung 5. A disposable host that the play converges end to end, before the fleet does, moves that error from production into a container that costs nothing to throw away.

Where a given defect belongs

The discipline is to push each defect class down to the cheapest rung that can catch it, and to be honest about the ones that cannot be pushed.

DefectLowest rung that catches it
tab in the indentation1 — the file will not parse
duplicate key in group_vars2 — yamllint, key-duplicates
mode: 644 meaning 012043 — risky-octal
shell used where systemd exists3 — command-instead-of-module
state: presnt5 — the module rejects it when it runs
role is not idempotent5 — the second converge reports changed
service task succeeds but service is dead5, only if the scenario verifies the outcome
role assumes systemd as PID 16 — a VM, because the container never had one
config reload drops in-flight requests7 — real traffic
the change is correct but the wrong hosts were targeted7 — and only if someone is watching

The last three rows are the shape of this part. Every layer below them is worth building, and none of them reaches the incident.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A playbook contains state: presnt on a package task. ansible-playbook --syntax-check exits 0. What has been established?

  2. Q2. Your repository fails ansible-lint with 900 violations, so a colleague changes the CI job to --profile min and it goes green. What actually changed?

  3. Q3. Which of these defects can be caught by a static layer — syntax check, yamllint or ansible-lint — before anything runs against a host? Select all that apply.

  4. Q4. After a production incident, the most useful question about testing is which is the lowest rung of the ladder that could have caught this defect, and why it did not run there.

Passing score: 75%. Answers are checked in this browser.