Stubbing library class methods in ChefSpec
18 Mar 2015One of the cool things about using Chef to automate your infrastructure is that all of your infrastructure is represented as code. An awesome benefit of having that representation is the ability to make assertions and test expected outcomes in by unit testing. ChefSpec, a unit testing library built on top of Rspec, comes bundled in the ChefDK and allows you to create unit tests for cookbooks. Unit testing cookbooks allows you to quickly iterate during development because your assertions are usually limited to verifying that the right resources passed the right messages and took the proper actions.
In ChefSpec all of the chef-runs are all in memory and the actions are no-ops. Execution time is very quick so the feedback loop is much better than waiting for an integration test to fully run.
Often times when unit testing you’ll find yourself stubbing various parts of your program to isolate the pieces that you’re actually trying to test. ChefSpec makes it very easy to stub node attributes but stubbing class methods in library Classes and Modules gets a bit tricky. Let’s take a look at what I mean with an example.
Here we have a cookbook called demo
that has a default recipe and a library
that implements the Demo module. In the Demo
module we’re going to have a method
foo()
that will return a string bar
. The helper methods in your cookbooks are
probably going to be doing something more complicated but we’ll run with this
example for simplicity.
# recipes/default.rb
log Demo.foo
# libraries/demo.rb
module Demo
def self.foo
'bar'
end
end
Now write a ChefSpec test and attemp to stub Demo.foo
# spec/spec_helper.rb
require_relative '../libraries/demo'
require 'chefspec'
require 'chefspec/berkshelf'
describe 'demo::default' do
let(:chef_run) { ChefSpec::SoloRunner.converge(described_recipe) }
before { allow(Demo).to receive(:foo).and_return('override') }
it 'stubs the demo library' do
expect(chef_run).to write_log('override')
end
end
And run the test
ryan@/tmp/demo(master):> bundle exec rspec spec/spec_helper.rb
F
Failures:
1) demo::default stubs the demo library
Failure/Error: expect(chef_run).to write_log('override')
expected "log[override]" with action :write to be in Chef run. Other log resources:
log[bar]
# ./spec/spec_helper.rb:11:in `block (2 levels) in <top (required)>'
Finished in 0.09651 seconds (files took 1.5 seconds to load)
1 example, 1 failure
Ut oh, what’s going on here? The answer lies deep in the heart of the chef-client
.
When we call .converge
on our ChefSpec runner we actually run through the entire
chef-client compile and execute phases. During that setup we have to compile our
cookbook which requires us to load any library files in the cookbook.
# lib/chef/run_context/cookbook_compiler.rb
def load_libraries_from_cookbook(cookbook_name)
files_in_cookbook_by_segment(cookbook_name, :libraries).each do |filename|
begin
Chef::Log.debug("Loading cookbook #{cookbook_name}'s library file: #{filename}")
Kernel.load(filename)
@events.library_file_loaded(filename)
rescue Exception => e
@events.library_file_load_failed(filename, e)
raise
end
end
end
You’ll see there on line #6 that we call Kernel.load
on each library file in the
cookbook. Doing this will completely wipe out the stubs that we created on line #9
of our ChefSpec test. So how do we fix this?
If your library is only going to be used during converge time you can stub it by passing
a block to converge()
with the class stubs
# spec/spec_helper.rb
let(:chef_run) do
ChefSpec::SoloRunner.converge(described_recipe) do
allow(Demo).to receive(:foo).and_return('override')
end
end
it 'stubs the demo library' do
expect(chef_run).to write_log('override')
end
end
That option won’t work in our case because the library is being used at compile time for the name of the log resource.
You could monkey patch the chef-client in a library and omit loading the library
# libraries/monkey_patches.rb
def load_libraries_from_cookbook(cookbook_name)
files_in_cookbook_by_segment(cookbook_name, :libraries).each do |filename|
begin
Chef::Log.debug("Loading cookbook #{cookbook_name}'s library file: #{filename}")
Kernel.load(filename)
@events.library_file_loaded(filename) unless cookbook_name == 'demo'
rescue Exception => e
@events.library_file_load_failed(filename, e)
raise
end
end
end
That would work but it’s a nasty hack at best. The cleanest option I’ve found is to write your library in a way that it won’t be reloaded.
# libraries/demo.rb
module Demo
def self.foo
'bar'
end
end unless defined?(Demo)
When the cookbook compiler tries to load the Demo library again it won’t
because we’ve previously loaded it in our spec_helper.rb
If we run our tests again with the modified library we should see it pass!
ryan@/tmp/demo(master):> bundle exec rspec spec/spec_helper.rb
.
Finished in 0.10684 seconds (files took 1.58 seconds to load)
1 example, 0 failures