Applying thorough testing to Puppet code: unit testing with rspec-puppet and fixtures from puppetlabs_spec_helper, static analysis with puppet-lint, syntax validation with puppet validate, and acceptance testing with Beaker inside a CI pipeline.

In episode 17 you explored PuppetDB Query Language (PQL) and exported resources, which let a node share resources with other nodes automatically. The Puppet code built throughout this series — modules, classes, profiles, hiera data, even exported resources — makes your infrastructure increasingly complex and far-reaching.
The bigger the code, the higher the risk that one small change in a manifest breaks hundreds of nodes in production. This is where testing comes in. A module without tests is like a bridge built without a load test: it looks sturdy, but can collapse when you need it most.
This episode covers how to test Puppet code professionally. We'll discuss unit testing with rspec-puppet and fixtures from puppetlabs_spec_helper, static analysis with puppet-lint, syntax validation with the puppet validate command, and acceptance testing with Beaker that can run automatically in CI like GitHub Actions and Jenkins.
Puppet is a declarative language, but it's still program code that can contain bugs: wrong types, wrong paths, wrong variable names, or resource conflicts. The problem is that bugs in infrastructure code don't show up as error messages on a developer's screen — they show up as service outages in the production environment.
There are four testing layers commonly applied to Puppet code:
| Layer | Tool | Question it answers |
|---|---|---|
| Static analysis | puppet-lint | Does the writing style follow the standard? |
| Syntax validation | puppet validate | Can the code be parsed without errors? |
| Unit testing | rspec-puppet | Does the class produce the correct resources? |
| Acceptance testing | Beaker | Does the code actually work on real systems? |
The four layers complement each other. Static analysis and syntax validation only take seconds to run, making them perfect for fast feedback on every commit. Unit testing gives confidence in class logic. Acceptance testing — though the slowest — provides the strongest proof that the code works in the real world.
Start with the fastest one. puppet-lint checks whether your code follows the Puppet Language Style Guide — for example indentation size, comma placement, or correct file naming. The tool can be installed as a Ruby gem:
gem install puppet-lint
puppet-lint manifests/ modules/Clean output means the code meets the standard. To silence rules irrelevant to your project, create a .puppet-lint.rc file at the module root:
--no-autoloader_layout-check
--fail-on-warningsThe --fail-on-warnings flag makes lint fail on any warning — perfect for CI. Remember, puppet-lint only judges style, not logical correctness. Logical correctness is the next layer's job.
puppet validate parses all manifests without executing them. It's the first safety net for catching typos, unbalanced braces, or wrong keywords:
puppet validate manifests/
puppet validate site.ppTip
Run puppet validate together with puppet-lint before every push. Both run in seconds and can catch most writing-level errors long before they reach heavier testing stages.
This command can also be applied to a single file, for example puppet validate manifests/webserver.pp. If the syntax is wrong, Puppet prints an error message with the offending line number.
Unit testing is the heart of Puppet code testing. The most common framework is rspec-puppet, which executes a class or defined type in an environment that simulates the Puppet compiler, then checks what resources are produced.
For specs to run, you need puppetlabs_spec_helper, a helper that standardizes the Puppet testing project structure. The helper automatically configures module paths, loads Facter, and manages fixtures. The structure generated by PDK is ready to use:
pdk new module webserver
cd webserver
pdk new class webserverPDK generates a structure with a spec/ directory complete with spec_helper.rb. Module dependencies needed at test time — for example stdlib or concat — are declared in .fixtures.yml:
fixtures:
forge_modules:
stdlib: puppetlabs/stdlib
concat: puppetlabs/concat
symlinks:
webserver: "#{source_dir}"Note
#{source_dir} is a built-in puppetlabs_spec_helper variable that points to the module directory. With fixtures, specs only load the dependencies actually needed, without relying on modules that happen to be installed on the local machine.
Here's a simple spec example for a webserver class that manages nginx:
require 'spec_helper'
describe 'webserver' do
on_supported_os.each do |os, os_facts|
context "pada sistem operasi #{os}" do
let(:facts) { os_facts }
it { is_expected.to compile }
it { is_expected.to contain_package('nginx').with_ensure('installed') }
it { is_expected.to contain_file('/etc/nginx/conf.d/app.conf')
.with_owner('root').that_requires('Package[nginx]') }
it { is_expected.to contain_service('nginx')
.with_ensure('running').that_subscribes_to('File[/etc/nginx/conf.d/app.conf]') }
end
end
endThe it { is_expected.to ... } pattern checks matchers. The compile matcher ensures the catalog compiles without errors, while contain_package, contain_file, and contain_service ensure the expected resources actually appear with the right parameters. Note the use of that_requires and that_subscribes_to to verify relationships between resources — exactly the material from episode 8.
Unit tests are run with Rake through puppetlabs_spec_helper:
bundle exec rake specImportant
Good unit tests should test behavior, not just assert there's no error. Get into the habit of writing assertions against the produced resources — is_expected.to contain_package('nginx') is far more useful than just is_expected.to compile.
If an assertion fails, rspec-puppet shows the full compiled catalog, so you can see the resource that should exist but is missing — or a resource that appears with the wrong parameters.
Unit testing proves the produced catalog is correct, but it doesn't prove the final result on real machines is correct. That's what Beaker is for: Puppet's acceptance testing framework that spins up virtual machines, containers, or cloud nodes, then actually runs the module there.
A nodeset describes the hosts under test. Here's a Docker nodeset example:
HOSTS:
ubuntu-agent:
platform: ubuntu-24.04-x86_64
hypervisor: docker
image: puppet/puppet-agent:8.8.1
roles:
- agent
CONFIG:
log_level: verbose
type: fossAcceptance specs are written as regular RSpec, for example:
require 'spec_helper_acceptance'
describe 'class webserver' do
let(:pp) { 'include webserver' }
it 'berfungsi tanpa error' do
apply_manifest(pp, catch_failures: true)
end
it 'bersifat idempotent' do
apply_manifest(pp, catch_changes: true)
end
endNotice the second assertion: catch_changes: true ensures that running the manifest twice produces no changes at all — this is the essence of idempotency, Puppet's core promise.
Testing is only useful if run continuously. Here's an example GitHub Actions workflow that runs lint and unit tests on every pull request:
name: Puppet CI
on:
pull_request:
push:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
- run: gem install puppet puppet-lint
- run: puppet validate manifests/
- run: puppet-lint --fail-on-warnings manifests/
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
- run: bundle install
- run: bundle exec rake specFor Jenkins the flow is similar: fast validate and lint stages run on every commit, while unit tests run in a separate stage. Acceptance tests with Beaker are usually scheduled on pull requests about to be merged or before a release, because they need more resources and more time.
bundle exec rake beakerTip
Apply the fail-fast principle: start with puppet-lint and puppet validate, which run in seconds, move up to unit tests, which run in minutes, and finish with Beaker, which runs for tens of minutes. The more expensive a test, the less often it should run — and the faster a test runs, the faster developers get feedback.
In this episode we built a testing pyramid for Puppet code: puppet-lint for static analysis, puppet validate for syntax validation, rspec-puppet with puppetlabs_spec_helper fixtures for unit testing, and Beaker for acceptance testing that proves idempotency on real machines. We also integrated everything into GitHub Actions and Jenkins so testing runs automatically on every code change.
Key takeaways:
catch_changes: true.In the next episode, episode 19, we'll cover Performance and Troubleshooting — how to analyze catalog compilation and report lag, tune Puppet Server on the JVM side, and fix common problems like certificate mismatch, catalog compilation errors, resource conflicts, and wrong facts. See you in the next episode!