Puppet unit testing for beginners

Puppet unit testing

Puppet unit testing for beginners

Thanks to PDK (Puppet Development Kit), writing unit and acceptance tests for Puppet modules has never been easier. We’ve already covered the PDK basics, so if you’re not familiar with this magnificent tool, make sure to glance over the Easy Puppet module development with PDK blog post. This time, we’ll cover the basics so that absolute beginners can get started with writing unit tests. We’ll leave the intro to acceptance testing for another time.

Why bother with writing tests?

Writing unit and acceptance tests for your Puppet module may seem like a waste of time, but in reality, well-written tests will save you time in the long run because you’ll be able to automate the boring process of manual testing. Not only that but having the proper tests in place will allow you to catch potential problems early. This alone will give you more confidence when deploying changes to production systems. Before getting to the nitty-gritty, let’s get the theory out of the way.

Unit testing is a method by which Puppet code is tested to ensure that it compiles on supported systems, that all declared resources are included, and that resources indeed have expected parameters declared. Unit tests validate the result of Puppet code – the compiled Puppet catalog used by Puppet nodes.

It’s not mandatory, but some people prefer to write unit tests first and deal with the Puppet code afterward. The idea behind this approach is that unit tests act as the blueprint for your Puppet code. Any deviation from the original specification will result in failed unit tests to catch mistakes early.

Unit testing is great, but it doesn’t cover testing of actual changes on the target system. This is where acceptance testing shines.
Acceptance testing is a method by which we verify if Puppet actually ensured the desired state on the target system. E.g. with acceptance tests, we can verify if Puppet installed declared packages, started specific services, and ensured that the configuration file contains declared content and expected permissions and ownership.

Puppet unit testing 101

Puppet unit tests are based on rspec-puppet, an RSpec test framework. If you are using PDK for Puppet module development (and you definitely should), then you’re already bootstrapped for writing unit tests. Thanks to PDK, you don’t have to worry about the rspec-puppet installation and setup. The icing on the cake is that  whenever you create a new class using PDK, the most basic unit test for that class is automatically created!

For example:

$ pdk new class example_module

---------------Files added--------------
/home/user/puppet-modules/example_module/spec/classes/example_module_spec.rb
/home/user/puppet-modules/example_module/manifests/init.pp

If you take a closer look at spec/classes/example_module_spec.rb file in your module’s directory, you will notice content similar to this one:

# frozen_string_literal: true

require 'spec_helper'

describe 'example_module' do
  on_supported_os.each do |os, os_facts|
    context "on #{os}" do
      let(:facts) { os_facts }

      it { is_expected.to compile }
    end
  end
end

The above unit test will run against every supported OS and OS version declared in the module’s metadata.json file, basic set of OS facts will be used for each scenario, and the unit test will pass if example_module class successfully compiles on each supported system.

Unit tests are executed using PDK command:

$ pdk test unit --parallel

Sample output should return a summary similar to this one:

Finished in 1.06 seconds (files took 18.17 seconds to load)
6 examples, 0 failures

6 examples, 0 failures

Took 20 seconds

Tip: if you’re looking for more verbose output to see details about successful tests, add –verbose argument to pdk test unit command. You can get the most verbose output with –debug argument if you’re looking for even more information.

Let’s move to a more specific example, where you defined two classes. With example_module::install class, you intend to install rsyslog package, and with example_module::config class you want to configure rsyslog configuration.

manifests/install.pp

class example_module::install {
  package { 'rsyslog':
    ensure => ‘installed’
  }

  service { 'rsyslog':
    ensure  => 'running',
    enable  => 'true',
    require => Package['rsyslog'],
  }
}

manifests/config.pp

class example_module::config {
  file {'/etc/rsyslog.d/10_modules.conf':
    ensure  => file,
    owner   => 'root',
    group   => 'root',
    mode    => '0640',
    content => '$ModLoad imudp.so',
    require => Package['rsyslog'],
    notify  => Service['rsyslog']
  }
}

Besides the basic requirement of a successful compilation, our unit tests will cover a couple more details.

spec/classes/install_spec.rb

# frozen_string_literal: true

require 'spec_helper'

describe 'example_module::install' do
  on_supported_os.each do |os, os_facts|
    context "on #{os}" do
      let(:facts) { os_facts }

      it { is_expected.to compile }
      it { is_expected.to contain_package('rsyslog').only_with_ensure('installed') }
      it { is_expected.to contain_service('rsyslog').with('ensure' => 'running', 'enable' => 'true').that_requires('Package[rsyslog]') }
    end
  end
end

Besides the basic requirement to compile, the unit test for example_module::install class now checks that the compiled catalog contains package rsyslog, but only with ensure => installed parameter. If the resource has additional parameters, this unit test will fail.

On the other hand, service rsyslog is expected to have at least ‘ensure’ => ‘running’ and ‘enable’ => ‘true’ parameters. Any additional parameters will be ignored.

Also, note that service rsyslog has dependency to ‘Package[rsyslog]’.

spec/classes/config_spec.rb

# frozen_string_literal: true

require 'spec_helper'

describe 'example_module::config' do
  on_supported_os.each do |os, os_facts|
    context "on #{os}" do
      let(:facts) { os_facts }
      let(:pre_condition) { 'include example_module::install' }

      it { is_expected.to compile }

      it {
        is_expected.to contain_file(
          '/etc/rsyslog.d/10_modules.conf',
        ).with(
          'ensure' => 'file',
          'owner'  => 'root',
          'group'  => 'root',
          'mode'   => '0640',
        ).that_requires(
          'Package[rsyslog]',
        ).that_notifies(
          'Service[rsyslog]',
        )
      }
    end
  end
end

Since example_module::config class contains a file resource that depends on ‘Package[rsyslog]’ and notifies ‘Service[rsyslog]’ that is declared in example_module::install class, we had to add a precondition to include that class before trying to compile example_module::config.

Without the let(:pre_condition) { ‘include example_module::install’ } part, the catalog compilation would fail because we’re trying to refer to resources that are not defined locally within the example_module::config class namespace.

What’s next?

The examples we’ve covered here are very simple ones, however, at this point, you should know enough to get started. We strongly recommend reading the official rspec-puppet documentation, which has examples for various kinds of tests.

We also recommend peeking into unit tests of modules published on Puppet Forge. The source code of the majority of modules is available at GitHub and seeing other people’s approaches to writing unit tests is a great way to improve your skills and learn new tricks.

In part two of Puppet testing for beginners we’ll cover the intro to acceptance testing, so watch this space!

Share this post