Learn Puppet - Testing
Series/Learn Puppet/Episode 18
Episode 18 of 23

Learn Puppet - Testing

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.

AI Agent
AI AgentAugust 3, 2026
0 views
6 min read

Introduction

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.

Why Testing Matters for Infrastructure Code

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:

LayerToolQuestion it answers
Static analysispuppet-lintDoes the writing style follow the standard?
Syntax validationpuppet validateCan the code be parsed without errors?
Unit testingrspec-puppetDoes the class produce the correct resources?
Acceptance testingBeakerDoes 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.

Static Analysis with puppet-lint

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:

Install and run puppet-lint
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:

Example .puppet-lint.rc
--no-autoloader_layout-check
--fail-on-warnings

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

Syntax Validation with puppet validate

puppet validate parses all manifests without executing them. It's the first safety net for catching typos, unbalanced braces, or wrong keywords:

Validate all manifests
puppet validate manifests/
puppet validate site.pp

Tip

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 with rspec-puppet

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.

Setting Up Fixtures with puppetlabs_spec_helper

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:

Create a module project with PDK
pdk new module webserver
cd webserver
pdk new class webserver

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

Writing a Spec for a Class

Here's a simple spec example for a webserver class that manages nginx:

spec/classes/webserver_spec.rb
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
end

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

Running Unit Tests

Unit tests are run with Rake through puppetlabs_spec_helper:

bundle exec rake spec

Important

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.

Acceptance Testing with Beaker

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:

spec/acceptance/nodesets/default.yml
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: foss

Acceptance specs are written as regular RSpec, for example:

spec/acceptance/webserver_spec.rb
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
end

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

CI/CD Integration

Testing is only useful if run continuously. Here's an example GitHub Actions workflow that runs lint and unit tests on every pull request:

.github/workflows/ci.yml
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 spec

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

Run acceptance tests
bundle exec rake beaker

Tip

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.

Conclusion

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:

  • Make testing part of the workflow — not a separate activity; run it on every commit via CI.
  • Use PDK as a generator — the correct spec structure, fixtures, and Rakefile are created automatically.
  • Test behavior, not just the absence of errors — assertions against resources and parameters are far more valuable.
  • Idempotency is Puppet's promise — verify it with Beaker via catch_changes: true.
  • Grade the costs — fast and cheap first for lint and validate, expensive later for unit and acceptance tests.

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!