Command Strings vs. Ruby Blocks When Unit Testing Chef Guard Clauses

December 12, 2016 |

Chef Guards

The great thing about the major Chef resources (file, directory, package, etc.) is that they’re idempotent, meaning that they will produce the same effect if executed more than once. Chef takes care of this under the hood. If, however, you find yourself needing to use one of the resources that executes arbitrary scripts, like execute or bash, you need to implement idempotency yourself with the use of guards. A guard describes the condition when your script should (only_if) or shouldn’t (not_if) be executed.

When working with an execute resource, the guard methods will accept either a command string, a Ruby string interpreted as a shell command, or a Ruby block (surrounded by {} or do…end). Let’s take a look at an example execute resource that uses a guard to specify that the script should be executed as long as a certain lockfile does not exist:

# command string
execute 'say hello' do 
  command 'echo "hello world"'

  not_if '[[ -f /tmp/lockfile]]'
end

# Ruby block
execute 'say hello' do 
  command 'echo "hello world"'
  
  not_if { ::File.exist?('/tmp/lockfile') }
end

 

When we run this, test we’ll get the following failure:

$ chef exec rspec spec/unit/recipes/default_spec.rb

Failures:

  1) test::default when the lockfile is absent executes the script
     Failure/Error: runner.converge(described_recipe)

     ChefSpec::Error::CommandNotStubbed:
       Executing a real command is disabled. Unregistered command:

           command("[[ -f /tmp/lockfile ]]")

       You can stub this command with:

           stub_command("[[ -f /tmp/lockfile ]]").and_return(...)

 

Oops! ChefSpec doesn’t let us run shell commands during tests and has shown us how to stub our guard. That’s good because we would’ve wanted to use a stub here anyway. If you’re unfamiliar with stubbing, it’s a way to force part of your code to behave in a certain way during a test so that you can validate how the code behaves in particular circumstances. In this case, we would want to have one test that simulates the absence of the lockfile and one that simulates its presence. And, as ChefSpec has pointed out, stubbing a shell command is very simple: just use stub_command. Here’s what the updated test looks like:

context 'when the lockfile is absent' do
  it 'executes the script' do
    stub_command("[[ -f /tmp/lockfile ]]").and_return(false)

    expect(chef_run).to run_execute('say hello')
  end
end

context 'when the lockfile is present' do
  it 'executes the script' do
    stub_command("[[ -f /tmp/lockfile ]]").and_return(true)

    expect(chef_run).not_to run_execute('say hello')
  end
end

 

And with that, we’re back in the green.

Unit Tests with ChefSpec: Ruby Blocks

Now let’s write tests for the Ruby block version of execute block. Rather than use stub_command, we’ll need to rely on some good old-fashioned RSpec stubbing:

# Just looking at a single test for the example's sake

context 'when the lockfile is absent' do
  it 'executes the script' do
    allow(File).to receive(:exists?).with('/tmp/lockfile').and_return(false)

    expect(chef_run).to run_execute('say hello')
  end
end

 

This time we get a different, and much more imposing, failure/error message:

1) test::default when the lockfile is absent executes the script

     Failure/Error: runner.converge(described_recipe)

       # received :exists? with unexpected arguments
         expected: ("/tmp/lockfile")
              got: ("/var/folders/x2/k4j769z97zdbb7tcm871k3q80000gn/T/d20161202-48532-ggaf9q/cookbooks/test/.uploaded-cookbook-version.json")
        Please stub a default value first if message might be received with other args as well.

 

What’s going on here? It turns out that we can’t stube the File class in this way because File is also used by ChefSpec when it runs the tests and our stub is messing that up. So we have to make sure that File is only stubbed where it appears in the test:

context 'when the lockfile is absent' do
  it 'executes the script' do
    allow(File).to receive(:exists?).and_call_original
    allow(File).to receive(:exists?).with('/tmp/lockfile').and_return(false)

    expect(chef_run).to run_execute('say hello')
  end
end

context 'when the lockfile is present' do
  it 'executes the script' do
    allow(File).to receive(:exists?).and_call_original
    allow(File).to receive(:exists?).with('/tmp/lockfile').and_return(true)

    expect(chef_run).not_to run_execute('say hello')
  end
end

 

The line ending with .and_call_original tells ChefSpec that if File::exists? is called with any arguments other than the one supply on the next line, that it should “call the original,” i.e. act in that case as if the stub didn’t exist.

DRYing Up the File Stub

As you can see, we now have four lines of stubs to accomplish the same thing that took just two with the first example. We can DRY this up a bit by putting the .and_call_original line in a before block outside of both context blocks:

before do
  allow(File).to receive(:exists?).and_call_original
end

context 'when the lockfile is absent' do
  it 'executes the script' do
    allow(File).to receive(:exists?).with('/tmp/lockfile').and_return(false)

    expect(chef_run).to run_execute('say hello')
  end
end

context 'when the lockfile is present' do
  it 'executes the script' do
    allow(File).to receive(:exists?).with('/tmp/lockfile').and_return(true)

    expect(chef_run).not_to run_execute('say hello')
  end
end

 

And that about does it. At the end of the day, whether I decide to use command strings or Ruby blocks in my guards depends on several factors, mostly the complexity of the logic and the difficulty of testing. In this case, either one works reasonably well, but the simplicty of the string command with stub_command is very appealing. The most important thing to be aware of, though, is what’s going on when you use a regular RSpec stub (allow) and get unexpected errors because more is being stubbed than you realized.

If you are interested in having our team help you in your projects, please contact us at info@reancloud.com. Also, if you like these kind of problems and want to join our team please contact us at careers@reancloud.com or visit us at http://www.reancloud.com/company/careers/.

Other Blog Posts

Blog

Top 5 Reasons to Utilize Cloud Computing in Financial Services
Read More
Blog

Is Migrating to the Cloud Safe for Financial Sector Companies?
Read More
Blog

REAN Cloud is one of the few AWS Premier Partners to achieve both AWS DevOps Competency and MSP Designation
Read More
Blog

7 Ways DevOps Can Save Your Company…Time and Money
Read More
Request Consultation
close slider