Chefspec: Unit test shell_out and powershell_out commands
Over the many projects that I have built using Chef, I sometimes need to do a quick and dirty command to pull details from the node as part of the converge. Most often, I use these commands in my custom resources. Of course, the challenge has always been with the unit testing. ChefSpec is the awesome unit testing tool based on RSpec and includes many great resources. If you have read any of my past posts, then you know that I am a stickler for unit testing code. One area of confusion can be how to properly stub out a call using ShellOut or PowershellOut mixins.
Let’s start by looking at a simple ShellOut call in a recipe:
# Let's pretend that we need to execute some 'unknown-command'. cmd =<<EOH unknown-command EOH variable_output = shell_out(cmd) raise "Error: #{variable_output.stderr}" unless variable_output.stderr == '' Chef::Log.info(variable_output.stdout)
Ok, we wrote our code and it works when we test it on a host. But now, how do we confirm this using Chefspec? If I just run the output of rspec on my test file, it will raise the following error:
Failure/Error: expect { chef_run }.to_not raise_error
expected no Exception, got #<RuntimeError: Error: sh: unknown-command: command not found>
So how do we handle this code properly in ChefSpec. After quite a bit of research, I found a few StackOverflow posts asking this same question and specifically, how to do this with RSpec v3. Let’s start by simply stubbing out every shell_out call.
require 'spec_helper' describe 'chefspec::test' do context 'Install prequisite components' do platforms = { 'ubuntu' => { 'versions' => %w(14.04 16.04) }, 'debian' => { 'versions' => %w(7.8) }, 'centos' => { 'versions' => %w(7.1.1503 7.2.1511) }, 'redhat' => { 'versions' => %w(7.1 7.2) } } platforms.each do |platform, components| components['versions'].each do |version| context "On #{platform} #{version}" do before do Fauxhai.mock(platform: platform, version: version) end let(:shellout) do # Creating a double allows us to stub out the response from Mixlib::ShellOut double(run_command: nil, error!: nil, stdout: '', stderr: '', exitstatus: 0, live_stream: '') end let(:chef_run) { ChefSpec::SoloRunner.new(platform: platform, version: version).converge(described_recipe) } it 'converges successfully' do # First we need to allow Mixlib::Shellout to receive the method :new and then return our double 'shellout' allow(Mixlib::ShellOut).to receive(:new).and_return(shellout) # Next, we need to tell the double to receive the method :stdout and then return some custom output allow(shellout).to receive(:stdout).and_return('success') expect { chef_run }.to_not raise_error end end end end end end
After adding the code to stub out the shell_out command, our tests are successful. I added comments in the above code to show the added lines but let’s start by looking at line #28:
double(run_command: nil, error!: nil, stdout: ”, stderr: ”, exitstatus: 0, live_stream: ”)
The Mixlib::ShellOut needs to have a double returned which returns the simulated output of the properties of the Mixlib. We place this at the top of our tests and run it before every test to make sure that it is available in other tests. Now that we have this object, we need to declare how we plan to use it. In my case, I want to verify that all the shell_out commands are properly stubbed so the following code will do this.
# First we need to allow Mixlib::Shellout to receive the method :new and then return our double ‘shellout’
allow(Mixlib::ShellOut).to receive(:new).and_return(shellout)
# Next, we need to tell the double to receive the method :stdout and then return some output
allow(shellout).to receive(:stdout).and_return(‘success’)
If we run the test now, we should receive confirmation that the test is working.
chefspec::test
Install prequisite components
On ubuntu 14.04
converges successfully
Perfect! We have stubbed out all of our shell_out commands. In fact, if we update our recipe to add another shell_out call, the spec test will still show success
cmd =<<EOH unknown-command EOH variable_output = shell_out(cmd) raise "Failed on the first command: #{variable_output.stderr}" unless variable_output.stderr == '' Chef::Log.info(variable_output.stdout) cmd =<<EOH second-unknown-command EOH variable_output = shell_out(cmd) raise "Failed on the second command: #{variable_output.stderr}" unless variable_output.stderr == '' Chef::Log.info(variable_output.stdout)
Now, I like complete tests so of course, I will want to test each of these independently. Specifically, I want to check that my error handler works when I don’t stub out the individual commands. Let’s look at a modified version of our spec tests:
require 'spec_helper' describe 'chefspec::test' do context 'Install prequisite components' do platforms = { 'ubuntu' => { 'versions' => %w(14.04 16.04) }, 'debian' => { 'versions' => %w(7.8) }, 'centos' => { 'versions' => %w(7.1.1503 7.2.1511) }, 'redhat' => { 'versions' => %w(7.1 7.2) } } platforms.each do |platform, components| components['versions'].each do |version| context "On #{platform} #{version}" do before do Fauxhai.mock(platform: platform, version: version) end let(:shellout) do # Creating a double allows us to stub out the response from Mixlib::ShellOut double(run_command: nil, error!: nil, stdout: '', stderr: '', exitstatus: 0, live_stream: '') end let(:failed_shell) do # We need to have a seperate double that we can use rather than trying to reuse the same one. # This prevents odd failures when calling two or more stubs. double(run_command: nil, error!: nil, stdout: '', stderr: 'failed', exitstatus: 0, live_stream: '') end let(:environment_var) do # When declaring a :new Mixlib::ShellOut, we need to pass this environment hash { environment: { 'LC_ALL' => 'en_US.UTF-8', 'LANGUAGE' => 'en_US.UTF-8', 'LANG' => 'en_US.UTF-8' } } end let(:chef_run) { ChefSpec::SoloRunner.new(platform: platform, version: version).converge(described_recipe) } it 'converges successfully' do # First we need to allow Mixlib::Shellout to receive the method :new and then return our double 'shellout' allow(Mixlib::ShellOut).to receive(:new).and_return(shellout) # Next, we need to tell the double to receive the method :stdout and then return some custom output allow(shellout).to receive(:stdout).and_return('success') expect { chef_run }.to_not raise_error end it 'raises an error if the second-unknown-command fails' do # The below command will return the default values in our double allow(Mixlib::ShellOut).to receive(:new).and_return(shellout) # We can stub individual shell_out commands by referencing the command in its entirety or using a regex like below. # When using the '.with' option we need to also pass an :environment hash. Note that I placed in at line# 35 and # reference it in my call below. allow(Mixlib::ShellOut).to receive(:new).with(/second-unknown-command/, environment_var).and_return(failed_shell) expect { chef_run }.to raise_error(RuntimeError,/Failed on the second command/) end end end end end end
With this updated code, we can run our rspec test once more and see that both tests are passing.
chefspec::test
Install prequisite components
On ubuntu 14.04
converges successfully
raises an error if the second-unknown-command fails
Now, we have an answer on how to test our Chef code completely and leverage Mixlib::ShellOut. This same unit testing framework works for the Mixlib::PowershellOut since powershell_out inherits shell_out. You can test this by changing the recipe to call powershell_out instead of shell_out. If you are like me and develop on a Mac, this will make your life so much easier for those intricate integrations with PowerShell. Happy coding!