Chef:Powerful Infrastructure Automation
上QQ阅读APP看书,第一时间看更新

Getting started with ChefSpec

In order to get started with ChefSpec, create a new cookbook directory (here it is $HOME/cookbooks/mycookbook) along with a recipes and spec directory:

mkdir -p ~/cookbooks/mycookbook 
mkdir -p ~/cookbooks/mycookbook/recipes
mkdir -p ~/cookbooks/mycookbook/spec

Now you will need a simple metadata.rb file inside your cookbook (here, this will be ~/cookbooks/mycookbook/metadata.rb):

maintainer       "Your name here"
maintainer_email "you@domain.com"
license          "Apache"
description      "Simple cookbook"
long_description "Super simple cookbook"
version          "1.0"
supports         "debian"

Once we have this, we now have the bare bones of a cookbook that we can begin to add recipes and tests to.

Installing ChefSpec

In order to get started with ChefSpec, you will need to install a gem that contains the ChefSpec libraries and all the supporting components. Not surprisingly, that gem is named chefspec and can be installed simply by running the following:

gem install chefspec

However, because Ruby gems often have a number of dependencies, the Ruby community has built a tool called Bundler to manage gem versions that need to be installed. Similar to how RVM provides interpreter-level version management and a way to keep your gems organized, Bundler provides gem-level version management. We will use Bundler for two reasons. In this case, we want to limit the number of differences between the versions of software you will be installing and the versions the author has installed to ensure that things are as similar as possible; secondly, this extends well to releasing production software—limiting the number of variables is critical to consistent and reliable behavior.

Locking your dependencies in Ruby

Bundler uses a file, specifically named Gemfile, to describe the gems that your project is dependent upon. This file is placed in the root of your project, and its contents inform Bundler which gems you are using, what versions to use, and where to find gems so that it can install them as needed.

For example, here is the Gemfile that is being used to describe the gem versions that are used when writing these examples:

source 'https://rubygems.org'

gem 'chef',        '11.10.0'
gem 'chefspec',    '3.2.0'
gem 'colorize',    '0.6.0'

Using this will ensure that the gems you install locally match the ones that are used when writing these examples. This should limit the differences between your local testing environments if you run these examples on your workstation.

In order to use a Gemfile, you will need to have Bundler installed. If you are using RVM, Bundler should be installed with every gemset you create; if not, you will need to install it on your own via the following code:

gem install bundler

Once Bundler is installed and a Gemfile that contains the previous lines is placed in the root directory of your cookbook, you can execute bundle install from inside your cookbook's directory:

user@host:~/cookbooks/mycookbook $> bundle install

Bundler will parse the Gemfile in order to download and install the versions of the gems that are defined inside. Here, Bundler will install chefspec, chef, and colorize along with any dependencies those gems require that you do not already have installed.

Creating a simple recipe and a matching ChefSpec test

Once these dependencies are installed, you will want to create a spec test inside your cookbook and a matching recipe. In keeping with the TDD philosophy, we will first create a file, default_spec.rb, in the spec directory. The name of the spec file should match the name of the recipe file, only with the addition of _spec at the end. If you have a recipe file named default.rb (which most cookbooks will), the matching spec test would be contained in a file named default_spec.rb. Let's take a look at a very simple recipe and a matching ChefSpec test.

Writing a ChefSpec test

The test, shown as follows, will verify that our recipe will create a new file, /tmp/myfile.txt:

require 'chefspec'  

describe 'mycookbook::default' do
  let(:chef_run) { 
    ChefSpec::Runner.new.converge(described_recipe) 
  }    

  it 'creates a file' do     
    expect(chef_run).to create_file('/tmp/myfile.txt')   
  end 
end  

Here, RSpec uses a describe block similar to the way Chef uses a resource block (again, blocks are identified by the do ... end syntax or code contained inside curly braces) to describe a resource, in this case, the default recipe inside of mycookbook. The described resource has a number of examples, and each example is described by an it block such as the following, which comes from the previous spec test:

it 'creates a file' do     
  expect(chef_run).to create_file('/tmp/myfile.txt')   
end 

The string given to the it block provides the example with a human-readable description of what the example is testing; in this case, we are expecting that the recipe creates a file. When our recipes are run through ChefSpec, the resources described are not actually created or modified. Instead, a model of what would happen is built as the recipes are executed. This means that ChefSpec can validate that an expected action would have occurred if the recipe were to be executed on an end host during a real client run.

Tip

It's important to note that each example block resets expectations before it is executed, so any expectations defined inside of a given test will not fall through to other tests.

Because most of the tests will involve simulating a Chef client run, we want to run the simulation every time. There are two options: execute the code in every example or use a shared resource that all the tests can take advantage of. In the first case, the test will look something like the following:

it 'creates a file' do  
  chef_run = ChefSpec::Runner.new.converge(described_recipe)    
  expect(chef_run).to create_file('/tmp/myfile.txt')   
end 

The primary problem with this approach is remembering that every test will have to have the resource running at the beginning of the test. This translates to a large amount of duplicated code, and if the client needs to be configured differently, then the code needs to be changed for all the tests. To solve this problem, RSpec provides access to a shared resource through a built-in method, let. Using let allows a test to define a shared resource that is cached for each example and reset as needed for the following examples. This resource is then accessible inside of each block as a local variable, and RSpec takes care of knowing when to initialize it as needed.

Our example test uses a let block to define the chef_run resource, which is described as a new ChefSpec runner for the described recipe, as shown in the following code:

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

Here, described_recipe is a ChefSpec shortcut for the name of the recipe provided in the describe block. Again, this is a DRY (don't repeat yourself) mechanism that allows us to rename the recipe and then only have to change the name of the description rather than hunt through the code. These techniques make tests better able to adapt to changes in names and resources, which reduces code rot as time goes by.

Building your recipe

The recipe, as defined here, is a very simple recipe whose only job is to create a simple file, /tmp/myfile.txt, on the end host:

file "/tmp/myfile.txt" do
  owner "root"   
  group "root"   
  mode "0755"   
  action :create 
end

Put this recipe into the recipes/default.rb file of your cookbook so that you have the following file layout:

mycookbook/
  |- recipes/
  |     |- default.rb
  |- spec/ 
        |- default_spec.rb

Executing tests

In order to run the tests, we use the rspec application. This is a Ruby script that comes with the RSpec gem, which will run the test scripts as spec tests using the RSpec language. It will also use the ChefSpec extensions because in our spec test, we have included them via the line require 'chefspec' at the top of our default_spec.rb file. Here, rspec is executed through Bundler to ensure that the desired gem versions, as specified in our Gemfile, are used at runtime without having to explicitly load them. This is done using the bundle exec command:

bundle exec rspec spec/default_spec.rb

This will run RSpec using Bundler and process the default_spec.rb file. As it runs, you will see the results of your tests, a . (period) for tests that pass, and an F for any tests that fail. Initially, the output from rspec will look like this:

Finished in 0.17367 seconds
1 example, 0 failures

RSpec says that it completed the execution in 0.17 seconds and that you had one example with zero failures. However, the results would be quite different if we have a failed test; RSpec will tell us which test failed and why.

Understanding failures

RSpec is very good at telling you what went wrong with your tests; it doesn't do you any good to have failing tests if it's impossible to determine what went wrong. When an expectation in your test is not met, RSpec will tell you which expectation was unmet, what the expected value was, and what value was seen.

In order to see what happens when a test fails, modify your recipe to ensure that the test fails. Look in your recipe for the following file resource:

file "/tmp/myfile.txt" do

Replace the file resource with a different filename, such as myfile2.txt, instead of myfile.txt, like the following example:

file "/tmp/myfile2.txt" do

Next, rerun your spec tests; you will see that the test is now failing because the simulated Chef client execution did something that was unexpected by our spec test. An example of this new execution would look like the following:

[user@host]$ bundle exec rspec spec/default_spec.rb
F

Failures:

 1) my_cookbook::default creates a file
 Failure/Error: expect(chef_run).to create_file('/tmp/myfile.txt')
 expected "file[/tmp/myfile.txt]" with action :create to be in Chef run. Other file resources:

 file[/tmp/myfile2.txt]

 # ./spec/default_spec.rb:9:in `block (2 levels) in <top (required)>'

Finished in 0.18071 seconds
1 example, 1 failure

Notice that instead of a dot, the test results in an F; this is because the test is now failing. As you can see from the previous output, RSpec is telling us the following:

  • The creates a file example in the 'my_cookbook::default' test suite failed
  • Our example failed in the ninth line of default_spec.rb (as indicated by the line that contained ./spec/default_spec.rb:9)
  • The file resource /tmp/myfile.txt was expected to be operated on with the :create action
  • The recipe interacted with a file resource /tmp/myfile2.txt instead of /tmp/myfile.txt

RSpec will continue to execute all the tests in the files specified on the command line, printing out their status as to whether they passed or failed. If your tests are well written and run in isolation, then they will have no effect on one another; it should be safe to execute all of them even if some fail so that you can see what is no longer working.

Expanding your tests

ChefSpec provides a comprehensive suite of tools to test your recipes; you can stub and mock resources (replace real behavior with artificial behavior, such as network or database connections), simulate different platforms, and more. Let's take a look at some more complex examples to see what other things we can do with ChefSpec.

Multiple examples in a spec test

Spec tests do not need to contain only one example; they can contain as many as you need. In order to organize them, you can group them together by what they describe and some shared context. In RSpec, context blocks contain examples that are relevant to the recipe or script being tested. Think of them as self-contained test suites within a larger test suite; they can have their own resources as well as setup and tear-down logic that are specific to the tests that are run in that context.

As an example, let's look at part of the spec test suite from the render_file example inside of ChefSpec itself. Consider this portion of the recipe:

file '/tmp/file' do
  content 'This is content!'
end

cookbook_file '/tmp/cookbook_file' do
  source 'cookbook_file'
end

template '/tmp/template' do
  source 'template.erb'
end

The recipe being shown has three resources: a template, a cookbook_file, and an ordinary file resource. A sample of the matching spec test (tests removed for formatting and ease of reading) contains an outer describe block, which tells us that we are executing tests for the render_file::default recipe and three separate context blocks. Each context describes a different portion of the recipe that is being tested and the expectations of that particular type of resource. Together, they are all part of the default recipe, but they behave very differently in what content they render as well as where and how they store files on the system.

In this example, the file context contains tests that pertain to the expected results of the file resource, the cookbook_file context is concerned with the cookbook_file resource, and so on:

describe 'render_file::default' do
  let(:chef_run) { 
    ChefSpec::Runner.new.converge(described_recipe) 
  }

  context 'file' do
    it 'renders the file' do
      expect(chef_run).to render_file('/tmp/file')
      expect(chef_run).to_not render_file('/tmp/not_file')
    end
  end

  context 'cookbook_file' do
    it 'renders the file' do
      expect(chef_run).to render_file('/tmp/cookbook_file')
      expect(chef_run).to_not   
            render_file('/tmp/not_cookbook_file')
    end
  end

  context 'template' do
    it 'renders the file' do
      expect(chef_run).to render_file('/tmp/template')
      expect(chef_run).to_not render_file('/tmp/not_template')
    end
  end

end

Contexts can be used to group together a set of examples that are related, not just ones that are specific to a particular resource. Consider the following example:

describe 'package::install' do 
  context 'when installing on Windows 2012' do 
  end
  context 'when installing on Debian' do 
  end 
  context 'when installing on FreeBSD' do 
  end
end

In the previous example, our spec test contained tests that are grouped together by the platform being executed on. Inside of each context, the Chef run will be constructed with a platform argument instead so that the expectations being tested will be considered against a run of the recipe on the platform in question rather than the host's operating system. This is incredibly useful, as we will see in the next section on testing for multiple platforms.

Testing for multiple platforms

One of the more non-trivial uses of ChefSpec is to simulate executing recipes on multiple platforms. This is useful for developers who are building recipes that need to support more than one operating system. Software packages such as PostgreSQL, MySQL, Java, PHP, Apache, and countless other applications can be installed on many different platforms. Because each platform varies in its installation mechanism, user creation, and other core features, being able to test recipes against all the supported platforms is incredibly useful.

Let's look at a hypothetical example to develop a recipe to install MySQL on Windows 2012 and some things we might want to validate during such a run:

context 'when run on Windows 2012' do
  let(:chef_run) do
    # construct a 'runner' (simulate chef-client) running
    # on a Windows 2012 host
    runner = ChefSpec::ChefRunner.new( 
        'platform' => 'windows',         
        'version' => '2012'         
    )
    # set a configuration variable       
    runner.node.set['mysql']['install_path'] = 'C:\\temp'      
    runner.node.set['mysql']['service_user'] = 'SysAppUser' 
    runner.converge('mysql::server')     
  end     

  it 'should include the correct Windows server recipe' do       
    chef_run.should include_recipe 'mysql::server_windows'     
  end  

  it 'should create an INI file in the right directory' do 
    ini_file = "C:\\temp\\mysql\\mysql.ini"
    expect(chef_run).to create_template ini_file
  end 
end

By constructing the runner with the platform and version options, the test will exercise running the mysql::server recipe and pretend as though it were running on a Windows 2012 host. This allows us to set up expectations about the templates that will be created, recipes that are being executed, and more on that particular platform.

Presuming that the mysql::server recipe was able to delegate to the OS-specific recipe on a given platform, we could write another test:

context 'when run on Debian' do
  let(:chef_run) do
    runner = ChefSpec::ChefRunner.new( 
        'platform' => 'debian'
    )
    runner.node.set['mysql']['install_path'] = '/usr/local'      
    runner.node.set['mysql']['service_user'] = 'mysql' 
    runner.converge('mysql::server')     
  end     

  it 'should include the correct Linux server recipe' do       
    chef_run.should include_recipe 'mysql::server_linux'     
  end  

  it 'should create an INI file in the right directory' do 
    ini_file = "/usr/local/mysql/mysql.ini"
    expect(chef_run).to create_template ini_file
  end 

  it 'should install the Debian MySQL package' do 
    expect(chef_run).to install_package('mysql-server') 
  end
end

In this way, we can write our tests to validate the expected behavior on platforms that we may not have direct access to in order to ensure that they will be performing the expected actions for a collection of platforms.