Sad to say, but not many modules include good tests. Good tests help keep embarrassing bugs from going out. Tests can save you a lot of time, avoiding the exhaustive debugging of an issue in production that turns out to be a wrong type used, or a similar mistake.
This chapter will teach you how to add good tests to your modules. When I got started, I struggled a lot due to a lack of good examples. This chapter provides good examples of each type of test you should be doing. It’s my intention that you’d be able to use the examples provided here like tinker toys, and build a good set of tests for your modules without much effort.
This chapter won’t provide exhaustive documentation of rspec or beaker, the testing tools of choice for the Puppet ecosystem. However, you should be able to build a good foundation of tests from what we cover in this chapter.
Let’s get started by setting up your testing tools.
The first time you set up to do testing, you’ll need to install some specific software.
You can use the Ruby version that comes with your operating system. If you are using the Vagrant testing setup documented in this book, it is easy to install Ruby into the system packages:
[vagrant@client~]$sudoyuminstall-yruby-develrubygemsrakelibxml2-devel
Guides for installing Ruby can be found in Appendix C.
Install the bundler gem for local installation of necessary dependencies:
$sudogeminstallbundler--no-ri--no-rdocFetching:bundler-1.11.1.gem(100%)Successfullyinstalledbundler-1.11.11geminstalled
Add the following lines to the Gemfile in the module directory:
gem'beaker-rspec',:require=>falsegem'pry',:require=>false
These lines will ensure that Beaker is installed along with the other dependencies in the next step. Beaker gem dependencies require these development libraries to compile binary extensions:
[vagrant@client~]$sudoyuminstall-ygcc-d++libxml2-devellibxslt-devel
If you haven’t done this already, you’ll need to install the puppetlabs_spec_helper and other dependency gems. The best way to do this is to run the bundler install command within the module directory. bundle will read the Gemfile and pull in rspec, rspec-puppet, beaker, and all of their dependencies. These are testing and template creation tools that simplify test creation:
[vagrant@clientpuppet]$bundlerinstallFetchinggemmetadatafromhttps://rubygems.org/..........Fetchingversionmetadatafromhttps://rubygems.org/..Resolvingdependencies...Installingrake11.1.1InstallingCFPropertyList2.2.8Usingdiff-lcs1.2.5...snipalonglistofgems...Installingrspec3.4.0Installingpuppet4.10.9Installingrspec-puppet2.3.2Installingpuppetlabs_spec_helper1.1.1Installingbeaker2.37.0Installingbeaker-rspec5.3.0Bundlecomplete!6Gemfiledependencies,95gemsnowinstalled.Use`bundleshow[gemname]`toseewhereabundledgemisinstalled.
sudo when running bundler. Its purpose is to vendor the gems locally in ~/.gems/, without affecting the system gems.The next step is to set up your module for testing. We’ll have to modify a few files to use the best tools for this.
Create a .fixtures.yml file to define the testing fixtures (dependencies) and where to acquire them for testing purposes. The information in this file should duplicate the dependencies in metadata.json.
The top of the file should always be the same. This tells the testing frame to copy the current module from its directory:
fixtures:symlinks:puppet:"#{source_dir}"
Then define each dependency for your module and the minimum version you support. You can list their names on the Puppet Forge or their GitHub URL. The following two examples will have similar effects. From the Forge:
forge_modules:stdlib:repo:"puppetlabs/stdlib"ref:4.5.1
From GitHub:
repositories:stdlib:repo:"git://github.com/puppetlabs/puppetlabs-stdlib"ref:"4.5.1"
If you are testing development of multiple modules, you may want to use symlinks to the source tree for each. Assuming the dependency is in the same directory structure:
symlinks:some_dependency:"#{source_dir}/../some_dependency"
Test that dependency setup worked properly like so:
$rakespec(in/etc/puppetlabs/code/environments/test/modules/puppet)Notice:Preparingtoinstallinto/etc/puppetlabs/code/environments/test/modules/puppet/spec/fixtures/modules...Notice:Downloadingfromhttps://forgeapi.puppetlabs.com...Notice:Installing--donotinterrupt.../etc/puppetlabs/code/environments/test/modules/puppet/spec/fixtures/modules└──puppetlabs-stdlib(v3.2.1)/usr/bin/ruby-I/usr/lib/ruby/gems/1.8/gems/rspec-support-3.2.2/lib:...Noexamplesfound.Finishedin0.00027seconds(filestook0.04311secondstoload)0examples,0failures
This shows that all fixtures (dependencies) were installed, but no examples (tests) were available. Let’s start building one now.
Now let’s build some tests for the module. We know, few people think that building tests is fun work—but it is important work that will save you time and effort down the road.
You should follow these guidelines for creating useful tests:
Let’s look at some examples that test each one of these situations.
Within your module directory, change into the spec/classes/ directory. Inside this directory, create a file named <modulename>_spec.rb.
[vagrant@client puppet]$ cd spec/classes [vagrant@client classes]$ $EDITOR puppet_spec.rb
First, we will define a test where the module test builds (compiles) a catalog successfully with the default options:
require'spec_helper'describe'puppet',:type=>'class'docontext'with defaults for all parameters'doit{is_expected.tocontain_class('puppet')}it{is_expected.tocontain_class('puppet::params')}it{is_expected.tocompile.with_all_deps}endend
Let’s go ahead and run the testing suite against this very basic test:
[vagrant@clientpuppet]$rakespec(in/etc/puppetlabs/code/modules/puppet)ruby-I/opt/puppetlabs/puppet/lib/ruby/gems/2.1.0/gems/rspec-support-3.2.2/lib:..Finishedin10.22seconds(filestook0.56629secondstoload)2examples,0failures
Now that our basic test passed, let’s go on to start checking the input parameters.
What if we expand the tests to include every possible input value? Rather than repeating each test with a different value, we use Ruby loops to iteratively build each of the tests from an array of values:
['1','0'].eachdo|repo_enabled|['emerg','crit','alert','err','warning','notice','info'].eachdo|loglevel|context"with repo_enabled =#{repo_enabled}, loglevel#{loglevel}"dolet:paramsdo{:repo_enabled=>repo_enabled,:loglevel=>loglevel,}endit{is_expected.tocontain_package('puppet-agent').with({'version'=>'1.10.9-1'})}endendend
Whoa, look at that. We added 15 lines of code and yet it’s performing 36 more tests:
[vagrant@clientpuppet]$rakespec(in/etc/puppetlabs/code/modules/puppet)ruby-I/opt/puppetlabs/puppet/lib/ruby/gems/2.1.0/gems/rspec-support-3.2.2/lib:......................................Finishedin10.53seconds(filestook0.56829secondstoload)38examples,0failures
Always test to ensure incorrect values fail. This example shows two tests that are intended to fail:
context'with invalid loglevel'dolet:paramsdo{:loglevel=>'annoying'}endit{is_expected.tocompile.with_all_deps}endcontext'with invalid repo_enabled'dolet:paramsdo{:repo_enabled=>'EPEL'}endit{is_expected.tocompile.with_all_deps}end
Run the tests to see what error messages are kicked back:
[vagrant@clientpuppet]$rakespecFailures:1)puppet4withinvalidloglevelshouldbuildacatalogw/odependencycyclesFailure/Error:shouldcompile.with_all_depserrorduringcompilation:ParameterloglevelfailedonClass[Puppet]:Invalidvalue"annoying".Validvaluesaredebug,info,notice,warning,err,alert,emerg,crit,verbose.atline1# ./spec/classes/puppet_spec.rb:52:in 'block (3 levels) in <top (required)>'2)puppet4withinvalidrepo_enabledshouldbuildacatalogw/odependencycyclesFailure/Error:shouldcompile.with_all_depserrorduringcompilation:Expectedparameter'repo_enabled'of'Class[Puppet]'tohavetypeEnum['0','1'],gotStringatline1# ./spec/classes/puppet_spec.rb:65:in 'block (3 levels) in <top (required)>'Finishedin10.81seconds(filestook0.57989secondstoload)40examples,2failures
Now let’s change the expect lines to accept the error we expect:
it do
is_expected.to compile.with_all_deps.and_raise_error(Puppet::Error,
/Invalid value "annoying". Valid values are/
)
end
and the following for the test of repo_enabled:
it do
is_expected.to compile.with_all_deps.and_raise_error(Puppet::Error,
/Expected parameter 'repo_enabled' .* to have type Enum/)
)
end
Now when you run the tests, you will see the tests were successful because the invalid input produced the expected error.
You can test to ensure that a file resource is created. The simplest form is:
it{is_expected.tocontain_file('/etc/puppetlabs/puppet/puppet.conf')}
Now, this only checks that the file exists, and not that it was modified correctly by the module. Test resource attributes using this longer form:
itdois_expected.tocontain_file('/etc/puppetlabs/puppet/puppet.conf').with({'ensure'=>'present','owner'=>'root','group'=>'root','mode'=>'0444',})end
Finally, you can also check the content against a regular expression. Here’s an example where we pass in a parameter, and then want to ensure it would be written to the file:
let:paramsdo{:loglevel=>'notice',}enditdois_expected.tocontain_file('/etc/puppetlabs/puppet/puppet.conf').with_content({/^\s*loglevel\s*=\s*notice/})end
Some manifests or tests may require that certain facts are defined properly. Inside the context block, define a hash containing the fact values you want to have available in the test:
let:factsdo{:osfamily=>'RedHat',:os=>{'family'=>'RedHat','release'=>{'major'=>'7','minor'=>'2'}},}end
By default, the hostname, domain, and fqdn facts are set from the fully qualified domain name of the host. To adjust the node name and these three facts for testing purposes, add this to the test:
let(:node) { 'webserver01.example.com' }
Within your module directory, change to the spec/fixtures/ directory. Inside this directory, create a subdirectory named hiera, containing a valid hiera.yaml file for testing:
[vagrant@clientpuppet]$cdspec/fixtures[vagrant@clientpuppet]$mkdirhiera[vagrant@clientclasses]$$EDITORhiera/hiera.yaml
You can change anything you want that is valid for Hiera in this configuration file, except for the datadir, which should reside within the fixtures path. Unless you desire a specific change, the following file could be used unchanged in every module:
# spec/fixtures/hiera/hiera.yaml---:backends:-yaml:yaml::datadir:/etc/puppetlabs/code/hieradata:hierarchy:-os/"%{facts.os.family}"-common
Now, add the following lines to a test context within one of the class spec files:
let(:hiera_config){'spec/fixtures/hiera/hiera.yaml'}hiera=Hiera.new(:config=>'spec/fixtures/hiera/hiera.yaml')
Now create your Hiera input files. The only necessary file is spec/fixtures/hiera/common.yaml. The others can be added only when you want to test things:
---puppet::loglevel:'notice'puppet::repo_enabled:'1'puppet::agent::status:'running'puppet::agent::enabled:true
You can use this Hiera data to configure the tests:
let:paramsdo{:repo_enabled=>hiera.lookup('puppet::repo_enabled',nil,nil),:loglevel=>hiera.lookup('puppet::loglevel',nil,nil),}end
This configuration allows you to easily test the common method of using Hiera to supply input parameters for your modules.
In some situations, your module will depend upon a class that requires some parameters to be provided. You cannot set parameters or use Hiera for that class, because it is out of scope for the current class and test file.
The workaround is to use a pre_condition block to call the parent class in resource-style format. Pass the necessary parameters for testing as parameters for the resource declaration, and this module instance will be created before your module is tested.
Here is an example from my mcollective module, which had to solve this exact problem:
describe'mcollective::client'dolet(:pre_condition)do'class { "mcollective": hosts => ["middleware.example.net"], client_password => "fakeTestingClientPassword", server_password => "fakeTestingServerPassword", psk_key => "fakeTestingPreSharedKey", }'end...testsforthemcollective::clientclass...
Unit tests should be created for any functions added by the module. Each function test should exist in a separate file, stored in the spec/functions/ directory of the module, and named for the function followed by the _spec.rb extension.
At a bare minimum, the test should ensure that:
Here is an example that should work for our make_boolean() example:
#! /usr/bin/env ruby -S rspecrequire'spec_helper'describe'make_boolean'dodescribe'should raise a ParseError if there is less than 1 argument'doit{is_expected.torun.with_params().and_raise_error(Puppet::ParseError)}enddescribe"should convert string '0' to false"doit{is_expected.torun.with_params("0").and_return(false)}endend
Within the spec/classes/ directory, create a file named agent_spec.rb. This is an exercise for you. Build the agent class, testing every valid and invalid input just like we did for the Puppet class.
For this, we simply want to test that the package, config file, and service resources are all defined:
require'spec_helper'describe'puppet::agent',:type=>'class'docontext'with defaults for all parameters'doitdois_expected.tocontain_package('puppet-agent').with({'version'=>'latest'})is_expected.tocontain_file('puppet.conf').with({'ensure'=>'file'})is_expected.tocontain_service('puppet').with({'ensure'=>'running'})endit{is_expected.tocompile.with_all_deps}endend
We have demonstrated how to build tests. Now, you should build out more tests for valid and invalid input.
Every object type provided by a module can be tested. Place tests for the other types in the directories specified in Table 17-1.
| Type | Directory |
|---|---|
| Class | spec/classes/ |
| Defined resource type | spec/defines/ |
| Functions | spec/functions/ |
| Node differences | spec/hosts/ |
The rspec unit tests discussed earlier in this chapter validate individual features by testing the code operation in the development environment. Rspec tests provide low-cost, easy-to-implement code validation.
In contrast, the Beaker test harness spins up (virtual) machines to run platform-specific acceptance tests against Puppet modules.
Beaker creates a set of test nodes (or nodeset) running each operating system and configuration you’d like to perform system tests for. Beaker tests are significantly slower and more resource-intensive than rspec tests, but they provide a realistic environment test that goes far beyond basic code testing.
As Beaker will need to run vagrant commands to create virtual machines to run the test suites on, you’ll need to run Beaker on your development system rather than one of the virtual machines you’ve been using so far. Following are the steps to set up and appropriate testing environment on your system.
gem install bundler command to install Bundler.bundle install to install the test dependencies.Create a directory within the module to contain the nodesets used for testing:
[vagrant@clientpuppet]$mkdir-pspec/acceptance/nodesets
Create a file name default.yml within this directory. This file should contain YAML data for nodes to be created for the test. The following example will create a test node using the same Vagrant box utilized within this book.
HOSTS:centos-7-x64:roles:-agentplatform:el-7-x86_64box:puppetlabs/centos-7.2-64-nocmhypervisor:vagrantvagrant_memsize:1024
Create additional nodeset files within this directory for all platforms that should be tested. Sample nodeset files for different operating systems can be found at Example Vagrant Hosts Files.
Create a file named spec/spec_helper_acceptance.rb within the module directory. This file defines the tasks needed to prepare the test system.
The following example will ensure Puppet and the module dependencies are installed:
require'beaker-rspec'require'pry'step"Install Puppet on each host"install_puppet_agent_on(hosts,{:puppet_collection=>'pc1'})RSpec.configuredo|c|# Find the module directorymodule_root=File.expand_path(File.join(File.dirname(__FILE__),'..'))# Enable test descriptionsc.formatter=:documentation# Configure all nodes in nodesetc.before:suitedo# Install module and dependenciespuppet_module_install(:source=>module_root,:module_name=>'puppet',)hosts.eachdo|host|# Install dependency modulesonhost,puppet('module','install','puppetlabs-stdlib'),{:acceptable_exit_codes=>[0,1]}onhost,puppet('module','install','puppetlabs-inifile'),{:acceptable_exit_codes=>[0,1]}endendend
This is generally a consistent formula you can use with any module. The adjustments specific to this module have been bolded in the preceding example.
The tests are written in rspec, like the unit tests created in the previous section. However ServerSpec tests are also available and can be used in combination with rspec tests.
For most modules that set up services, the first test should be an installation test to validate that it installs with the default options, and that the service is properly configured.
Create a file named spec/acceptance/installation_spec.rb within the module directory. The following example defines some basic tests to ensure that the package is installed and that the service runs without returning an error:
require'spec_helper_acceptance'describe'puppetclass'docontext'default parameters'do# Using puppet_apply as a helperit'should install with no errors using default values'dopuppetagent=<<-EOSclass{'puppet::agent':}EOS# Run twice to test idempotencyexpect(apply_manifest(puppetagent).exit_code).to_noteq(1)expect(apply_manifest(puppetagent).exit_code).toeq(0)enddescribepackage('puppet-agent')doit{shouldbe_installed}enddescribefile('/etc/puppetlabs/puppet/puppet.conf')doit{shouldbe_a_file}enddescribeservice('puppet')doit{shouldbe_enabled}endendend
You can and should create more extensive tests that utilize different input scenarios for the module. Each test should be defined as a separate file in the spec/acceptance/ directory, with a filename ending in _spec.rb.
You can find more information, including details about writing good tests, in these places:
rspec.info site.serverspec.org site.To spin up virtual machines to run the test, you’ll need to run the acceptance tests from a system that has Vagrant installed. Your personal system will work perfectly fine for this purpose.
Let’s go ahead and run the entire acceptance test suite:
$bundleexecrspecspec/acceptanceHypervisorforcentos-7-x64isvagrantBeaker::Hypervisor,foundsomevagrantboxestocreate==>centos-7-x64:ForcingshutdownofVM...==>centos-7-x64:DestroyingVMandassociateddrives...createdVagrantfileforVagrantHostcentos-7-x64Bringingmachine'centos-7-x64'upwith'virtualbox'provider...==>centos-7-x64:Importingbasebox'puppetlabs/centos-7.2-64-nocm'...Progress:100%==>centos-7-x64:MatchingMACaddressforNATnetworking...
The test will spin up a Vagrant instance for each node specified in the spec/acceptance/nodeset/ directory. It will run the tests on each of them, and output the status to you, as shown here:
puppet::agent class
default parameters
should install with no errors using default values
Package "puppet-agent"
should be installed
File "/etc/puppetlabs/puppet/puppet.conf"
should be a file
Service "puppet"
should be enabled
Destroying vagrant boxes
==> centos-7-x64: Forcing shutdown of VM...
==> centos-7-x64: Destroying VM and associated drives...
Finished in 17.62 seconds (files took 1 minute 2.88 seconds to load)
4 examples, 0 failures
As shown, all tests have completed without any errors.
Test failure messages are clear and easy to read. However, they won’t necessarily tell you why something isn’t true. When testing this example, I got the following errors back:
2)puppet::agent class default parameters Package"puppet-agent"should be installed Failure/Error: it{should be_installed}expected Package"puppet-agent"to be installed# ./spec/acceptance/installation_spec.rb:17:in `block (4 levels) in <top>'
Beaker provides extensive debug output, showing every command and its output on the test nodes. Preserve the test system for evaluation by setting the BEAKER_destroy environment variable to no:
$BEAKER_destroy=noBEAKER_debug=yesbundleexecrspecspec/acceptance
You can isolate debugging to a single host configuration. For example, if there were a nodeset in spec/acceptance/nodeset/centos-6-x86.yml, then the following command would run the tests in debug mode on the CentOS 6 node only:
$ BEAKER_set=centos-6-x86 BEAKER_debug=yes rspec spec/acceptance
Debug output will show the complete output of every command run by Beaker on the node, like so:
* Install Puppet on each host centos-7-x64 01:36:36$rpm --replacepkgs -ivh Retrieving http://yum.puppetlabs.com/puppetlabs-release-el-7.noarch.rpm Preparing...########################################Updating / installing... puppetlabs-release-7-11########################################warning: rpm-tmp.KNaXPi: Header V4 RSA/SHA1 Signature, key ID 4bd6ec30: NOKEY centos-7-x64 executed in 0.35 seconds
Avoid reprovisioning the node each time when you are debugging code. The following process is a well-known debugging pattern:
Run the test but keep the node around:
$BEAKER_destroy=noBEAKER_debug=yesrspecspec/acceptance
Rerun the test using the existing node:
$BEAKER_destroy=noBEAKER_provision=noBEAKER_debug=yes\bundleexecrspecspec/acceptance
Run the test with a fresh provisioning:
$rspecspec/acceptance
When all else fails, access the console of a host so that you can dig around and determine what happened during the test. At the point where investigation is necessary, add binding.pry to the _spec.rb test file that is failing:
describepackage('puppet-agent')doit{shouldbe_installed}end# Access host console to debug failurebinding.pry
Then rerun the test:
$BEAKER_destroy=nobundleexecrspecspec/acceptance...From:learning-puppet4/puppet4/spec/acceptance/installation_spec.rb@line20:16:describepackage('puppet-agent')do17:it{shouldbe_installed}18:end19:# Make the debug console available=>20:binding.pry21:[1]pry(RSpec::ExampleGroups::PuppetAgentClass::DefaultParameters)>
Pry is a debugging shell for Ruby, similar but more powerful than IRB. Documenting all features is beyond the scope of this book, but the following commands have proven very useful to investigate host state.
You can cat and edit local files directly:
>>catspec/acceptance/installation_spec.rb>>editspec/acceptance/installation_spec.rb
Enter shell-mode to be placed in a local directory. Prefix commands with a period to execute them on the local system:
>>shell-modelearning-puppet4/puppet4$.lsspec/acceptance/installation_spec.rbnodesets/
Get a list of hosts in the text, with their index number:
>>hosts.each_with_indexdo|host,i|"#{i} #{host.hostname()}\n";end;1centos-7-x64
Use the host index to execute a command on the host. Add a trailing semicolon to avoid debugger verbosity:
>>onhosts[0],'rpm -qa |grep puppet';centos-7-x6402:50:14$rpm-qa|greppuppetpuppet-3.8.4-1.el7.noarchpuppetlabs-release-7-11.noarch
Whoops, the wrong version of Puppet was installed. The default is still currently to install the old open source version (Puppet v3). Changing the type parameter to aio in the nodeset solved this problem.
For more information about using this debugger, refer to Pry: Get to the Code.
Beaker can test dozens of other resources, including network interfaces, system devices, logical configurations, and TLS certificates. A complete list of resource types with should and expect examples can be found at ServerSpec Resource Types.
Beaker is a fast-moving project with new features added constantly. Check the Beaker GitHub project for the latest documentation.
There are a number of Puppet module skeletons that preinstall frameworks for enhanced testing above and beyond what we’ve covered. You may want to tune the module skeleton you use to include testing frameworks and datasets consistent with your release process.
Place the revised skeleton in the ~/.puppetlabs/opt/puppet/cache/puppet-module/skeleton directory, or specify it on the puppet module generate command line with --module_skeleton_dir=path/to/skeleton.
The following are skeletons I have found useful at one time or another (in their own words):
This skeleton is very opinionated. It’s going to assume you’re going to start out with tests (both unit and system), that you care about the Puppet style guide, test using Travis, keep track of releases and structure your modules according to strong conventions.
puppet-module-skeleton README
This skeleton is popular and recommended by Puppet Labs Professional Services Engineers.
The module comes with everything you need to develop infrastructure code with Puppet and feel confident about it.
puppet-skeleton README
This skeleton includes helpers to spin up Vagrant instances and run tests on them.
This is a skeleton project for Web Operations teams using Puppet. It ties together a suite of sensible defaults, best current practices, and re-usable code. The intentions of which are two-fold:
New projects can get started and bootstrapped faster without needing to collate or rewrite this material themselves.
The standardization and modularization of these materials makes it easier for ongoing improvements to be shared, in both directions, between different teams.
puppet-skeleton README
A complete working solution with:
Puppet master and agent nodes on Puppet Open Source
Spotify Puppet Explorer and PuppetDB
Hiera configuration
Dynamic Git environments by r10k
External puppet modules installation and maintenance by r10k
Landrush local DNS
Couple of bootstrap Puppet classes:
common::filebucket- use of filebucket on all files
common::packages- central packages installation from hiera
common::prompt- a Bash command prompt with support for Git and Mercurialpuppet-os-skeleton README
You can find many others by searching for “puppet skeleton” on GitHub. In particular, you can find skeletons specialized for specific application frameworks: OpenStack, Rails, Django, and so on.
You may have found a tricky problem not covered by the examples here. At this point, it is best to refer to the original vendor documentation:
Much of this documentation is dated, but still valid. There are open bugs to provide Puppet 4.x-specific documentation, and I will update this section as soon as it is available.
Each class and defined type should have both unit and acceptance tests defined for it. Some good rules of thumb for tests are:
In this chapter, we have discussed how to test modules for:
This has covered the necessary tests that should be included in every module. In the next chapter, we’re going to look at how to publish modules on the Puppet Forge.