21 Dec 2016 · Software Engineering

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

    6 min read
    Contents

    Post originally published on David Pell. Republished with author’s permission.

    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

    I am much more comfortable writing Ruby than bash, so I tend to prefer the second form for purposes of readability – and especially if the guard is more complicated than a simple check like the one above!

    Unit Tests with ChefSpec: Guards

    Unit tests are a powerful way to increase our confidence in the correctness of custom logic like the above guards, and it’s here that we’ll see that the Ruby block method isn’t quite so simple when the tests are considered alongside the implementation. Let’s start with a simple test of the command string version:

    # spec/unit/recipes/default_spec.rb
    
    describe 'test::default' do
      let(:chef_run) do
        runner = ChefSpec::SoloRunner.new
        runner.converge(described_recipe)
      end
    
      context 'when the lockfile is absent' do
        it 'executes the script' do
          expect(chef_run).to run_execute('say hello')
        end
      end
    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)
    
           #<File (class)> 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 stub 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 I find the simplicty of the string command with stub_command 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.

    P.S. Would you like to learn how to build sustainable Rails apps and ship more often? We’ve recently published an ebook covering just that — “Rails Testing Handbook”. Learn more and download a free copy.

    Leave a Reply

    Your email address will not be published. Required fields are marked *

    Avatar
    Writen by:
    David is a Sr. Automation Engineer at REĀN Cloud who enjoys Ruby, Chef, AWS, and DevOps as a people-centered philosophy. Find him on Twitter and at his blog.