The Agile movement brought many fresh ideas which enriched the world of software development and changed way how we look to it. This philosophy left marks on how we look our infrastructure too.

By treating the infrastructure equally important as other parts of the project, there was a need for making sure that infrastructure is equally trusty and tested as the code we write. Thanks to the Chef and other popular configuration management tools, those ideas are becoming our daily routine.

The first and most crucial thing for creating sustainable and well structured software is to have tests for it. With tests we can easily change and improve the code in the future with no worries about breaking things.

The most basic and easiest way to talk about testing in general is unit testing. In this tutorial will try to help you with basic principles of unit testing with Chef. We will use ChefSpec, unit testing framework built on top of the RSpec. To follow the tutorial you will need some basic understanding of Chef principles.

Prerequisites

As we are working with Chef managed infrastructure we need to install standard environment that comes with the Chef Development Kit. It includes all essential tools for managing the code that runs your infrastructure including tools for testing it like ChefSpec that we will use for unit testing. For more details about setup of Chef development environment, you can always consult the series intro tutorial Infrastructure as Code with Chef: Introduction to Chef. Beside a Chef workstation you will need to have basic understanding how Chef works.

Write Cookbook

To get started and feel what ChefSpec looks like and what we can achieve with it, lets write some basic recipes.

For this purpose we’ll write cookbook that installs Vim editor with some additional packages on popular Linux distributions.

First we’ll need to generate a basic cookbook with:

knife cookbook create tdi-example-vim -o .

Our freshly generated cookbook should look like this:

.
|-- CHANGELOG.md
|-- LICENSE
|-- README.md
|-- attributes
|-- definitions
|-- files
|   `-- default
|-- libraries
|-- metadata.rb
|-- providers
|-- recipes
|   `-- default.rb
|-- resources
`-- templates
    `-- default

Lets write recipes to install Vim from source or official packages.

#./recipes/default.rb

begin
  include_recipe "vim::#{node['vim']['install_method']}"
rescue Chef::Exceptions::RecipeNotFound
  Chef::Log.warn "A #{node['vim']['install_method']} recipe does not exist for the platform_family: #{node['platform_family']}"
end

The best way to support various platforms with different package names is to use value_for_platform helper. It takes a data structure (effectively, a hash of hashes) and returns a value which represents the value for the platform on which the recipe is being executed.

#./recipes/package.rb

vim_base_pkgs = value_for_platform({ ["ubuntu", "debian", "arch"] => {"default"
=> ["vim"]}, ["redhat", "centos", "fedora", "scientific"] => {"default" =>
["vim-minimal","vim-enhanced"]}, "default" => ["vim"] })

vim_base_pkgs.each do |vim_base_pkg|
  package vim_base_pkg
end

node['vim']['extra_packages'].each do |vimpkg|
  package vimpkg
end

Installation from source is straightforward too. Beside installing the basic dependencies only thing that your recipe should do is to download and untar desired version of source code, and run make command.

#./recipes/source.rb

cache_path     = Chef::Config['file_cache_path'] source_version =
node['vim']['source']['version']

node['vim']['source']['dependencies'].each do |dependency| package dependency do
action :install end
end

remote_file "#{cache_path}/vim-#{source_version}.tar.bz2" do
  source "http://ftp.vim.org/pub/vim/unix/vim-#{source_version}.tar.bz2"
  checksum node['vim']['source']['checksum']
  notifies :run, "bash[install_vim]", :immediately
end

bash "install_vim" do
  cwd cache_path
  code <<-EOH
    mkdir vim-#{source_version}
    tar -jxf vim-#{source_version}.tar.bz2 -C vim-#{source_version} --strip-components 1
    (cd vim-#{source_version}/ && ./configure #{node['vim']['source']['configuration']} && make && make install)
  EOH
  action :nothing
end

This gives us a nice starting point: provided with good default attributes, we should have a working cookbook. But how to verify if that code really works, or if it’s valid code in the first place?

Of course you can run that cookbook on an actual server or virtual machine and inspect the results manually. However, as soon as you start working with more than a few cookbooks that brings too much overhead, making cookbook verification a slow and painful process. Another approach to this problem is to write unit tests that can verify that appropriate Chef methods are run, instead of the actual results of a Chef run.

Note that unit tests are not a substitute for integration tests, but are much better than nothing. They can save you from bugs early in the development phase, and save you a lot of time, without any provisioning costs. Testing results of real Chef run on provisioned node is a topic for itself, and deserves a separate post.

Verifying Chef cookbooks with ChefSpec tests

To start writing unit tests for your cookbook, just create as separate spec file for each recipe into /spec folder.

So lets start with writing specs for default recipe.

Because the default recipe is only including a recipe for each installation method, that is where we should start our testing.

First thing that we should do is to include ChefSpec gem, and define our an in-memory Chef run. Because it’s not provisioning any actual machine it should be very fast, and of course easy to configure.

#./spec/default_spec.rb

require 'chefspec'

describe 'vim::default' do
  let :chef_run do
    ChefSpec::SoloRunner.new(platform: 'ubuntu', version: '12.04')
  end
end

Next step is to define some examples inside described recipe, and set expectations for our test cases.

The most basic thing that we can test is that our Chef run sets a default attribute for the installation method. In RSpec manner you should add a new “it block” to our described recipe:

#./spec/default_spec.rb

  it "should have default install_method 'package'" do
    chef_run.converge(described_recipe)
    expect(chef_run.node['vim']['install_method']).to eq('package')
  end

Lets run our tests.

$ chef exec spec
.

Finished in 0.1274 seconds (files took 2.93 seconds to load)
1 example, 0 failures

You should see that our test case successfully passed.

Now when we have verified that proper attribute is set, lets test if the right recipe is included.

#./spec/default_spec.rb

  it "should include the vim::package recipe when install_method = 'package'" do
    chef_run.converge(described_recipe)
    expect(chef_run).to include_recipe('tdi-example-vim::package')
  end

  it "should include the vim::source recipe when install_method = 'source'" do
    chef_run.node.set['vim']['install_method'] = 'source'
    chef_run.converge(described_recipe)
    expect(chef_run).to include_recipe('tdi-example-vim::source')
  end

As you can see beside standard RSpec matchers, ChefSpec defines matchers for all of Chef’s core resources. More info about them you can find on ChefSpec documentation.

To wrap up tests for default recipe, spec should look like this, and it should successfully pass.

#spec/default.rb

require 'chefspec'

describe 'vim::default' do
  let :chef_run do
    ChefSpec::SoloRunner.new(platform: 'ubuntu', version: '12.04')
  end

  it 'should default to install_method = "package"' do
    chef_run.converge(described_recipe)
    expect(chef_run.node['vim']['install_method']).to eq('package')
  end

  it 'should include the vim::package recipe when install_method = "package"' do
    chef_run.converge(described_recipe)
    expect(chef_run).to include_recipe('vim::package')
  end

  it 'should include the vim::source recipe when install_method = "source"' do
    chef_run.node.set['vim']['install_method'] = 'source'
    chef_run.converge(described_recipe)
    expect(chef_run).to include_recipe('vim::source')
  end
end

Because our tests are written in plain Ruby, as cookbooks, we can dynamically generate test cases for multiple platforms. So lets test our package recipe agains different versions of Ubuntu, Debian and RedHat Linux distributions.

#spec/package_spec.rb

require 'chefspec'

describe 'tdi-example-vim::package' do
  package_checks = {
    'ubuntu' => {
      '12.04' => ['vim'],
      '14.04' => ['vim']
    },
    'debian' => {
      '7.0' => ['vim'],
      '7.1' => ['vim']
    },
    'redhat' => {
      '6.3' => ['vim-minimal', 'vim-enhanced']
    }
  }

  package_checks.each do |platform, versions|
    versions.each do |version, packages|
      packages.each do |package_name|

        it "should install #{package_name} on #{platform} #{version}" do
          chef_runner = ChefSpec::SoloRunner.new(platform: platform, version: version)
          chef_runner.converge(described_recipe)

          expect(chef_runner).to install_package(package_name)
        end

      end
    end
  end
end

As you can see testing against other platforms is only matter of params that you provide to your in-memory run. In this example we iterated trough a list of platforms and dynamically generated examples. Each of these examples defined expectations that desired packages will be installed.

Wrapping up

In this tutorial, we have stepped our Chef development environment, generated basic cookbook, and added basic unit tests for it. Thanks to the ChefSpec we have easily verified that cookbook meets requirements that we have set for supporting multiple Linux distributions.

Tools we used:

Full source of this tutorial can be found on GitHub. If you’re interested, you can also follow the steps taken through individual commits in the repository.