When it comes to testing your Chef cookbooks, currently there seem to be two tools that build the standard in the Chef community: ChefSpec and Test Kitchen (with Serverspec, which is the third tool). Where Serverspec can be used for an outside-in testing approach for your infrastructure, ChefSpec is more like unit testing or input testing for your Chef cookbooks. In this blogpost I’ll try to provide some best practices and snippets for common use cases.

ChefSpec’s goal is to simulate a convergence run of Chef in-memory and test expected behavior against this in-memory results. Due to only converging the node in-memory, it provides fast feedback thus making an ideal test framework for a commit stage in a Continuous Integration pipeline for your cookbooks.

ChefSpec Setup

Let’s assume you have a cookbook structure like this:

.
├── Gemfile
├── README.md
├── attributes
│   └── default.rb
├── metadata.rb
├── recipes
│   └── default.rb
└── spec
    ├── recipe_default_spec.rb
    └── spec_helper.rb

In order to use ChefSpec in your cookbook, make sure to add it to your Gemfile

source 'https://rubygems.org'
gem 'chefspec'

After you created your Gemfile, run bundle install to install all dependencies.

The spec_helper.rb configures the rspec framework and looks like this:

require 'chefspec'

Dir['*_spec.rb'].each { |f| require File.expand_path(f) }
at_exit { ChefSpec::Coverage.report! }

All you have to do to run ChefSpec is to go to the root of your cookbook and run rspec:

$ rspec

app_virtualbox::default
  adds the expected apt repository
  installs expected apt package for VirtualBox
  installs expected apt package for dkms

Finished in 0.3915 seconds (files took 2.52 seconds to load)
3 examples, 0 failures


ChefSpec Coverage report generated...

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

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

Writing your first Spec

Given the following recipe that you want to write a spec for:

apt_repository 'oracle-virtualbox' do
  uri 'http://download.virtualbox.org/virtualbox/debian'
  key 'http://download.virtualbox.org/virtualbox/debian/oracle_vbox.asc'
  distribution 'trusty'
  components ['contrib']
end

package "virtualbox-#{node['virtualbox']['version']}" do
  action :install
end

package 'dkms' do
  action :install
end

here’s how this spec might look like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
require 'spec_helper'

describe 'app_virtualbox::default' do

  virtual_box_version = '5.0'

  let(:chef_run) do
    ChefSpec::SoloRunner.new do | node |
      node.set['virtualbox']['version']= virtual_box_version
    end.converge(described_recipe)
  end

  it 'adds the expected apt repository' do
    expect(chef_run).to add_apt_repository('oracle-virtualbox').with(
      :uri => 'http://download.virtualbox.org/virtualbox/debian',
      :key => 'http://download.virtualbox.org/virtualbox/debian/oracle_vbox.asc',
      :distribution => 'trusty',
      :components => ['contrib']
    )
  end

  it 'installs expected apt package for VirtualBox' do
    expect(chef_run).to install_package("virtualbox-#{virtual_box_version}")
  end

  it 'installs expected apt package for dkms' do
    expect(chef_run).to install_package('dkms')
  end

end

In line 3 we open a describe block for the recipe under test (app_virtualbox::default). Line 7 simulates the Chef run - in memory, as I described before - and “overrides” a few node attributes that we’ll use in our specs later.

Line 14 shows how we can assert that resources have been invoked by our Chef run. The add_apt_package method is a so-called matcher, which is defined for every resource that ships with Chef in the ChefSpec framework (see the extensive example folder on Github for a complete list of default matchers and their usage). Additional matchers can either be defined in your own cookbooks or community cookbooks or can be added to your specs for those cookbooks, which do not define their own matchers as we’ll see later.

Using the with method in line 114, you can further specify the attributes that you want the resource to be called with.

Running this spec, the output will look like this (given you use the parameters -f d -c when calling rspec):

$ rspec -f d -c

app_virtualbox::default
  adds the expected apt repository
  installs expected apt package for VirtualBox
  installs expected apt package for dkms

Finished in 0.3915 seconds (files took 2.52 seconds to load)
3 examples, 0 failures

“Advanced” Use Cases

Mocking Recipe Inclusion

Let’s say you have a recipe that includes two other recipes:

include_recipe 'java'
include_recipe 'jenkins::master'

and you want to test whether your recipe includes the expected recipes (and only those), then you can use the following spec to do this:

require 'spec_helper'

describe 'app_jenkins_master::default' do
  let(:chef_run) { ChefSpec::SoloRunner.converge(described_recipe) }

  before do
    allow_any_instance_of(Chef::Recipe)
      .to receive(:include_recipe).with('java')
    allow_any_instance_of(Chef::Recipe)
      .to receive(:include_recipe).with('jenkins::master')
    allow_any_instance_of(Chef::Recipe)
  end

  it "includes the java cookbook" do
    expect_any_instance_of(Chef::Recipe)
      .to receive(:include_recipe).with(java)
    chef_run
  end

  it "includes the jenkins::master cookbook" do
    expect_any_instance_of(Chef::Recipe)
      .to receive(:include_recipe).with(jenkins::master)
    chef_run
  end

end

Mocking Data Bags

If your recipe under test uses a data bag:

projects_databag = data_bag_item('jenkins', 'projects')
projects_databag['projects'].each do | project_id, project_config |
  # ...
end

you can mock them as well:

require 'spec_helper'

describe 'cookbook::jenkins_jobs' do

  let(:chef_run) { ChefSpec::SoloRunner.converge(described_recipe) }

  databag_stub = {
    :projects => {
      :app_chef_node => {
        :project_type => 'type1',
        :repository_url => 'git@github.com:user/repos1.git'
      },
      :app_jenkins_master => {
        :project_type => 'type2',
        :repository_url => 'git@github.com:user/repos2.git'
      }
    }
  }

  before do
    stub_data_bag_item('jenkins', 'projects').and_return(databag_stub)
  end

  databag_stub[:projects].each do | project_id, project_config |

    # ...

  end

Mocking Shell Commands (e.g. for not_if conditions)

Given you use a shell command in a not_if attribute in your resource:

execute 'install dpkg package libgecode' do
  command 'sudo dpkg -i libgecode /tmp/libgcode.deb'
  not_if "apt-cache show libgecode | grep 1.7.4"
end

and you want to stub this command to either return true or false in your spec, use this snippet:

require 'spec_helper'

describe 'cookbook::packages' do

  stubbed_command = 'apt-cache show libgecode | grep 1.7.4'

  let(:chef_run) { ChefSpec::SoloRunner.new().converge(described_recipe) }

  describe 'installs the expected dpkg packages if they are missing' do
      before(:each) do
        stub_command(stubbed_command).and_return(false)
      end

      describe 'what to do if command returns FALSE' do
        # ...
      end

    end

  end

  describe 'does not install the expected packages if they are already available' do
      before(:each) do
        stub_command(stubbed_command).and_return(true)
      end

      describe 'what to do if command returns TRUE' do
        # ...
      end

    end
  end

end

Mocking File.exists? and IO.read

Let’s assume, you want to test a recipe like this:

timezone_active_path = '/etc/localtime'
timezone_source_dir = '/usr/share/zoneinfo'

timezone_content_path = "#{timezone_source_dir}/#{node['timezone']}"

raise(ArgumentError, "Zoneinfo specified in node['timezone'] not found in #{timezone_source_dir}: #{node['timezone']}") unless File.exists? timezone_content_path

file timezone_active_path do
  content IO.read(timezone_content_path)
  action :create
  owner 'root'
  group 'wheel'
  mode 0444
end

Therefore, you have to figure out, how to mock a call to File.exists? and IO.read in order to mock out the behavior and check whether an exception is raised in the expected circumstances. You can do that with this before block:

before(:each) do
  allow(File).to receive(:exists?).with(anything).and_call_original
  allow(File).to receive(:exists?).with('/path/to/file').and_return true
  allow(IO).to receive(:read).with(anything).and_call_original
  allow(IO).to receive(:read).with('/path/to/file').and_return 'file content'
end

Notifications to other resources

Let’s assume we have a service resource for the Apache server like this

service 'apache22' do
  supports :status => true, :start => true, :stop => true, :restart => true
  action :enable
end

and another resource that writes a configuration file, which should effect a restart of the Apache server

cookbook_file '/usr/local/etc/apache22/modules.d/010_fcgid.conf' do
  source '010_fcgid.conf'
  owner 'root'
  group 'wheel'
  mode '0644'
  action :create
  notifies :restart, 'service[apache22]', :delayed
end

then you can use the following Chefspec test to check whether the notification happens:

it "creates a configuration file `/usr/local/etc/apache22/modules.d/010_fcgid.conf`" do
  expect(chef_run).to create_cookbook_file(/usr/local/etc/apache22/modules.d/010_fcgid.conf).with(
                        owner:    'root',
                        group:    'wheel',
                        mode:     '0644',
                        source:   '010_fcgid.conf'
                      )
  cookbook_file = chef_run.cookbook_file('/usr/local/etc/apache22/modules.d/010_fcgid.conf')
  expect(cookbook_file).to notify("service[apache22]")
end

Testing for raised exceptions during Chef run

Assume you have a recipe code like this:

raise(ArgumentError, "Some error message") unless File.exists? '/path/to/file'

Then you can check for the exception being raised in your Chef run using

describe 'what happens if file does not exist' do

  let(:chef_run) { ChefSpec::SoloRunner.converge(described_recipe) }

  before(:each) do
    allow(File).to receive(:exists?).with(anything).and_call_original
    allow(File).to receive(:exists?).with('/path/to/file').and_return false
  end

  it 'fails with ArgumentError' do
    expect{chef_run}.to raise_error(ArgumentError)
  end

end

Write Custom Matchers

If you are using a 3rd-party cookbook that does not provide a matcher for a resource defined within this cookbook, you can write your own matcher. Let’s say you have this resource in your recipe (it actually doesn’t provide a matcher!):

ssh_known_hosts_entry 'my.host.com' do
  key 'the_host_key'
end

then you can write a matcher ssh_known_hosts_entry_matcher.rb file like this:

if defined?(ChefSpec)

  ChefSpec.define_matcher :ssh_known_hosts_entry

  #
  # Matches, if known_hosts_entry resource was called
  #
  # @example This is an example
  #   expect(chef_run).to create_ssh_known_hosts_entry('HOST')
  #
  # @param [String] resource_name
  #   the resource name
  #
  # @return [ChefSpec::Matchers::ResourceMatcher]
  #
  def create_ssh_known_hosts_entry(resource_name)
    ChefSpec::Matchers::ResourceMatcher.new(:ssh_known_hosts_entry, :create, resource_name)
  end
end

and include it in your spec_helper.rb like that:

require 'chefspec'

require_relative 'ssh_known_hosts_entry_matcher'

Dir['*_spec.rb'].each { |f| require File.expand_path(f) }

If you have several matchers that you have to implement on your own, you might come up with a folder structure like this:

spec
├── custom_matchers
│   ├── rbenv_matcher.rb
│   └── ssh_known_hosts_entry_matcher.rb
├── ... your specs ...
└── spec_helper.rb

and you can use the following snippet in your spec_helper.rb to include those matchers:

Dir.glob("#{File.dirname(__FILE__)}/custom_matchers/*_matcher.rb").each { |matcher_implementation| require matcher_implementation }

Using ChefSpec with Berkshelf

In case you are using Berkshelf for cookbook dependency management, simply add the following line to your spec_helper.rb:

require 'chefspec/berkshelf'

ChefSpec will then automatically resolve cookbook dependencies for you.

Test Coverage

The following snippet in your spec_helper.rb file will switch test coverage on:

at_exit { ChefSpec::Coverage.report! }

In your rspec output you will then find something like this:

ChefSpec Coverage report generated...

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

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

The .rspec File

You can add a file .rspec in your cookbook’s root directory that holds a default configuration for your rspec test runs:

--color
--format documentation

this will result in a colored output with the decriptions in the describe and it blocks rendered as output on the shell.

Rake Helper Task

A simple Rake task can help you to run all ChefSpec tests in a subdirectory at once. Let’s assume that your coobooks are stored in a subfolder cookbooks:

desc 'Runs ChefSpec tests on all cookbooks in the `cookbooks` directory'
task :chefspec do |t, args|
  Dir.glob('cookbooks/*') do |cookbook|
    sh "cd cookbooks/#{cookbook} && bundle exec rspec"
  end
end

Further Resources