Unit testing with ChefSpec and rspec-puppet

ChefSpec is a Chef cookbook RSpec unit testing framework written by the great Seth Vargo (Opscode Chef, Hashicorp). ChefSpec helps to create a fast feedback loop, locally simulate Chef runs (solo or server) over the code, and issue a code coverage statement for every resource used. It integrates very well with Berkshelf, so cookbook dependencies are easily handled during the testing process.

We'll create unit tests for the cookbooks created in Chapter 6, Fundamentals of Managing Servers with Chef and Puppet, that covers the most common tests, such as convergence issues, packages installation, services status check, file and template creation, access rights, recipe inclusion, stubbing data bag searches, or even intercepting expected errors. These tests are so generic, we'll be able to reuse them in all our future recipes and get started on more.

Getting ready

To step through this recipe, you will need the following:

  • A working Chef installation on the workstation
  • A working Chef client configuration on the remote host
  • The Chef code from Chapter 6, Fundamentals of Managing Servers with Chef and Puppet, or any custom Chef code

How to do it…

ChefSpec unit tests are found in the spec/unit/recipes folder of every Chef cookbook. Depending on how we created our cookbooks, this folder may already exist.

To illustrate, let's start from the apache cookbook from Chapter 6, Fundamentals of Managing Servers with Chef and Puppet, but any similar custom cookbook is equally good.

If the spec/unit/recipes directory doesn't exist, create it by executing the following command:

$ mkdir -p spec/unit/recipes

In this recipes directory in spec/unit are found the ChefSpec unit tests, typically:

$ tree spec/
spec/
├── spec_helper.rb
└── unit
    └── recipes
        ├── default_spec.rb
        └── virtualhost_spec.rb

Each recipe gets its matching ChefSpec file. In this case, our simple cookbook contains two recipes, so we get two specs.

The Spec Helper

It's helpful to have a common set of requirements for all the concerned cookbook tests. The default is to have it named spec_helper.rb at the root of the spec/unit directory. We suggest to include at least three requirements:

  • ChefSpec itself
  • The Berkshelf plugin for dependencies management
  • Immediately start the code coverage

Here's our sample spec_helper.rb file:

require 'chefspec'
require 'chefspec/berkshelf'
ChefSpec::Coverage.start!

Testing a successful Chef run context

We'll now unit test the default apache cookbook recipe. Our first step is to require the helper created earlier in the default_spec.rb file. It will be required in all of our future tests:

require 'spec_helper'

All unit tests start with a descriptive block, as given here:

describe 'cookbook::recipe_name' do 
  [...]
end

Inside this block, we want to simulate the Chef run in a simulated CentOS 7.2 environment, with the default attributes. This is the context, and we expect this Chef run to not raise any errors:

describe 'apache::default' do
  context 'Default attributes on CentOS 7.2' do
    let(:chef_run) do
      runner = ChefSpec::ServerRunner.new(platform: 'centos', version: '7.2.1511')
      runner.converge(described_recipe)
    end

    it 'converges successfully' do
      expect { chef_run }.to_not raise_error
    end
  end
end

To find the exact past or future CentOS version we might need, we can go to the CentOS mirror site, http://mirror.centos.org/centos/, or read a full list of available simulated platforms at https://github.com/customink/fauxhai/tree/master/lib/fauxhai/platforms.

Execute our first unit test using chef exec rspec (it's using the bundled rspec from the Chef DK):

$ chef exec rspec --color
.....

Finished in 0.82521 seconds (files took 1.87 seconds to load)
5 examples, 0 failures


ChefSpec Coverage report generated...

  Total Resources:   2
  Touched Resources: 0
  Touch Coverage:    0.0%

Untouched Resources:

  yum_package[httpd]                 apache/recipes/default.rb:7
  service[httpd]                     apache/recipes/default.rb:11

We see the simulated Chef run execution times, as well as a coverage report (0%, as we didn't test anything for now). ChefSpec even shows us what's not unit tested yet!

A nice option is the documentation RSpec formatter, so we have descriptions of what's being tested. At the end of this section, we'll have something like this, using this formatter:

$ chef exec rspec --format documentation --color

apache::default
  Default attributes on CentOS 7.2
    converges successfully
    installs httpd
    enables and starts httpd service

apache::virtualhost
  Default attributes on CentOS 7.2
    converges successfully
    creates a virtualhost directory
    creates and index.html file
    creates a virtualhost configuration file

Finished in 1.14 seconds (files took 2.56 seconds to load)
7 examples, 0 failures


ChefSpec Coverage report generated...

  Total Resources:   5
  Touched Resources: 5
  Touch Coverage:    100.0%

You are awesome and so is your test coverage! Have a fantastic day!

Testing a package installation

Our default recipe starts by installing the httpd package. Here's how to test it using ChefSpec, inside the context we created earlier:

    it 'installs httpd' do
      expect(chef_run).to install_package('httpd')
    end

Execute rspec again and see the touch coverage attain 50% as one of the two resources from the default recipe is now tested.

Testing services status

The default recipe enables and starts the httpd service. Here's how to test if both actions are handled by the code using ChefSpec, inside the context created earlier:

    it 'enables and starts httpd service' do
      expect(chef_run).to enable_service('httpd')
      expect(chef_run).to start_service('httpd')
    end

Our test coverage is now 100% for the default recipe as we tested both declared resources.

Testing another recipe from the same cookbook

As we have two recipes in the apache cookbook, let's create tests for our second recipe—virtualhost_spec.rb. Start it exactly like the first one, with a description, context, and an initial test for a valid Chef run:

require 'spec_helper'

describe 'apache::virtualhost' do
  context 'Default attributes on CentOS 7.2' do
    let(:chef_run) do
      runner = ChefSpec::ServerRunner.new(platform: 'centos', version: '7.2.1511')
      runner.converge(described_recipe)
    end

    it 'converges successfully' do
      expect { chef_run }.to_not raise_error
    end
  end
end

Execute RSpec and see the coverage fall from 100% to 40%. Three new resources are now untested, from the apache::virtualhost recipe:

$ chef exec rspec --color
[...]
ChefSpec Coverage report generated...

  Total Resources:   5
  Touched Resources: 2
  Touch Coverage:    40.0%

Untouched Resources:

  directory[/var/www/default]        apache/recipes/virtualhost.rb:8
  file[/var/www/default/index.html]   apache/recipes/virtualhost.rb:15
  template[/etc/httpd/conf.d/default.conf]   apache/recipes/virtualhost.rb:22

The good news is that ChefSpec still tells us which resources are not tested!

Testing directory creation

This particular apache::virtualhost recipe starts by creating a directory. Here's how we can test for this directory existence, along with its ownership parameters:

    it 'creates a virtualhost directory' do
      expect(chef_run).to create_directory('/var/www/default').with(
        user: 'root',
        group: 'root'
      )
    end

Code coverage is now 60%!

Testing file creation

The same recipe then creates an index file. This is how we test it's created with the required ownership:

    it 'creates and index.html file' do
      expect(chef_run).to create_file('/var/www/default/index.html').with(
        user: 'root',
        group: 'root'
      )
    end

Code coverage is now 80%!

Testing templates creation

The recipe ends with the creation of Apache VirtualHost from a template. This is how to test it's in place with the default attributes:

    it 'creates a virtualhost configuration file' do
      expect(chef_run).to create_template('/etc/httpd/conf.d/default.conf').with(
        user: 'root',
        group: 'root'
      )
    end

All in all, we've now covered 100% of our resources!

As the output says:

You are awesome and so is your test coverage! Have a fantastic day!

Stubbing data bags for searches

The mysite cookbook we created earlier contains a search in a data bag to later populate a file with content. The thing is, we're unit testing, and no real Chef server is answering requests. So the tests are failing: the simulated Chef run doesn't end well because a search can't be executed. Fortunately, ChefSpec allows us to stub the data bag with real content. So here's how it looks in spec/unit/recipes/default_spec.rb from the mysite cookbook:

describe 'mysite::default' do
  context 'Default attributes on CentOS 7.2' do
    let(:chef_run) do
      runner = ChefSpec::ServerRunner.new(platform: 'centos', version: '7.2.1511')
      runner.create_data_bag('webusers', {
        'john' => {
          'id' => 'john',
          'htpasswd' => '$apr1$AUI2Y5pj$0v0PaSlLfc6QxZx1Vx5Se.'
        }
      })
      runner.converge(described_recipe)
    end

    it 'converges successfully' do
      expect { chef_run }.to_not raise_error
    end
  end
end

Now the simulated Chef run has a webusers data bag and some sample data to work with!

Testing recipes inclusion

It's very common to include recipes inside another recipe. Typically, when using notifications for restarting a service from a file change, the concerned service must be included in the recipe where the file resource is located; otherwise, the code most probably works by chance because the required dependent cookbook is included elsewhere! Here's how to test for a cookbook inclusion:

    it 'includes the `apache` recipes' do
      expect(chef_run).to include_recipe('apache::default')
      expect(chef_run).to include_recipe('apache::virtualhost')
    end

We now ensure that dependencies are always included.

Intercepting errors in tests

Sometimes we have to work with third-party cookbooks, that may somehow raise errors. It's the case with the official MySQL cookbook, which depends on the SELinux cookbook for the RHEL/CentOS platform. This cookbook, for some reason, doesn't work with ChefSpec, so when converged, it errors out the following string: chefspec not supported!. ChefSpec stops there, and say the Chef run is in error. As we don't have any power on why is that, here's a workaround to expect a very specific error from a Chef run, and this will be helpful many times later:

    it 'converges successfully' do
      # The selinux cookbook raises this error.
      expect { chef_run }.to raise_error(RuntimeError, 'chefspec not supported!')
    end

We've seen a selection of the most common and reusable unit tests for Chef cookbooks!

There's more…

Using Puppet, Puppet Labs is providing a repository containing several useful tools we will use in this chapter—the Puppet Labs Spec Helper. Let's install it:

$ sudo puppet resource package puppetlabs_spec_helper provider=puppet_gem

For unit testing, rspec-puppet is the counterpart of ChefSpec for Puppet, and has been installed as a dependency of puppetlabs_spec_helper. We will now add a unit test for each manifest in our Apache module. First of all, we need a Rakefile to create the required targets. Fortunately, the puppetlabs_spec_helper gem provides such targets. Let's create a Rakefile in the top-level directory of our Apache module with the following content:

require 'puppetlabs_spec_helper/rake_tasks'

All unit tests should remain in a spec directory. Before writing any test, we also need a helper script that will be common to all tests. Let's create it in spec/spec_helper.rb. This file should contain the following line:

require 'puppetlabs_spec_helper/module_spec_helper'

We are now ready to write unit tests. We have four manifests in our module, and we are about to create a unit test for each of them. Here are the goals:

  • For the apache/manifests/init.pp manifest: The unit test needs to validate the manifest is compiling, the apache2 package installation is done, and the apache2 service is running and activated on boot.
  • For the apache/manifests/vhost.pp manifest: The unit test should ensure the virtual host is created in /etc/apache2/sites-available and activated in /etc/apache2/sites-enabled.
  • For the apache/manifests/htpasswd.pp manifest: The unit test should ensure a htpasswd file is generated correctly.
  • For the apache/manifests/htaccess.pp manifest: The unit test should ensure a .htaccess file is generated correctly.

Let's try the first one! Since the manifest contains a class declaration, the unit test should be in spec/classes. The class name is apache; this will be the base name of the file containing the test. Each test file should be suffixed by _spec.rb, so let's create spec/classes/apache_spec.rb with the following content:

require 'spec_helper'

# Description of the "apache" class
describe 'apache' do
  # Assertion list
  it { is_expected.to compile.with_all_deps }
  it { is_expected.to contain_package('apache2').with(
     {
      'ensure' => 'present',
    }
  ) }
  it { is_expected.to contain_service('apache2').with(
     {
      'ensure' => 'running',
      'enable' => 'true',
    }
  ) }
end

Unit tests are in descriptive blocks, with a list of assertions. Here, we have the three assertions we mentioned earlier when describing the goal of the test.

Now, let's run the unit test using the spec rake target:

$ rake spec
...

Finished in 2.42 seconds (files took 1.53 seconds to load)
3 examples, 0 failures

That's it! Our three assertions have been tested successfully!

The three other tests should be placed under spec/defines, this is because the corresponding manifests declare a define statement. Let's create:

  • spec/defines/apache_vhost_spec.rb, with the following content:
    require 'spec_helper'
    
    # Description of the "apache::vhost" 'define' resource
    describe 'apache::vhost', :type => :define do
    
      # As a requirement, we should load the apache class
      let :pre_condition do
        'class {"apache":;}'
      end
    
      # Define a title for the 'define' resource
      let :title do
        'mysite'
      end
    
      # Parameters list 
      let :params do 
        {
          :website => 'www.sample.com' , 
          :docroot => '/var/www/docroot',
        }
      end
    
      # Assertions list
      it { is_expected.to compile }
      it { is_expected.to contain_class('apache') }
      it { is_expected.to contain_file('/etc/apache2/sites-available/www.sample.com.conf')
        .with_content(/DocumentRoot /var/www/docroot/) }
      it { is_expected.to contain_file('/etc/apache2/sites-enabled/www.sample.com.conf').with(
        'ensure' => 'link',
        'target' => '/etc/apache2/sites-available/www.sample.com.conf'
      ) }
    end
  • spec/defines/apache_htpasswd_spec.rb, with the following content:
    require 'spec_helper'
    
    # Description of the "apache::htpasswd" 'define' resource
    describe 'apache::htpasswd', :type => :define do
    
      # As a requirement, we should load the apache class
      let :pre_condition do
        'class {"apache":;}'
      end
    
      # Define a title for the 'define' resource
      let :title do
        'myhtpasswd'
      end
    
      # Parameters list
      let :params do 
        {
          :filepath => '/tmp/htpasswd' , 
          :users => [ { "id" => "user1", "htpasswd" => "hash1" } ]
        }
      end
    
      # Assertion list
      it { is_expected.to compile }
      it { is_expected.to contain_class('apache') }
      it { is_expected.to contain_file('/tmp/htpasswd')
        .with_content(/user1:hash1/) }
    end
  • spec/defines/apache_htaccess_spec.rb, with the following content:
    require 'spec_helper'
    
    # Description of the "apache::htaccess" 'define' resource
    describe 'apache::htaccess', :type => :define do
    
      # As a requirement, we should load the apache class
      let :pre_condition do
        'class {"apache":;}'
      end
    
      # Define a title for the 'define' resource
      let :title do
        'myhtaccess'
      end
    
      # Parameters list
      let :params do 
        {
          :filepath => '/tmp/htpasswd' , 
          :docroot => '/var/www/docroot',
        }
      end
    
      # Assertion list
      it { is_expected.to compile }
      it { is_expected.to contain_class('apache') }
      it { is_expected.to contain_file('/var/www/docroot/.htaccess')
        .with_content(/AuthUserFile /tmp/htpasswd/) }
    end

Now we have all our unit tests, and each one validates the initial target we defined earlier. The total number of assertions is 13, and we can now run the complete test suite:

$ rake spec
.............

Finished in 2.88 seconds (files took 1.52 seconds to load)
13 examples, 0 failures

Note

The Rake targets provided also contain a lint target that can be used with rake lint. We can use this target directly instead of puppet-lint manually as we did earlier.

See also

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.144.86.138