Testing Chef's `ruby_block`s with ChefSpec

I like to ensure that all my code is as well unit tested as possible, both so I can quickly iterate changes, and to ensure that future changes don't inadvertently break functionality.

However, when I first needed to touch Chef's ruby_blocks, I found that they were not being executed as part of the ChefSpec run. During a ChefSpec run, I could confirm that the block would be called on a code path, but I wouldn't be able to confirm until an integration test (i.e. converging with Test Kitchen) that the code inside them would actually work.

Using ruby_block.block.call

Investigating this further, I found that it was possible to use block.old_run_action(:action) to trigger the block to run as if the block was actually called during a Chef run:

  # recipe
  ruby_block "get the '#{key_name}' key" do
    block do
      node.run_state["public_key/#{safe_key_name}"] = ::File.read("#{node['etc']['passwd'][username]['dir']}/.ssh/#{safe_key_name}.pub")
      Chef::Recipe::RubyBlockHelper.run_state_public_key(node, username, safe_key_name)
    end
  end

  # spec
  it ' ... ' do
    expect(chef_run).to run_ruby_block('get the \'blah blah key\' key')

    allow(::File).to receive(:read)
      .and_call_original
    allow(::File).to receive(:read)
      .with('/run/lib/www-spectatdesigns-co-uk/.ssh/blah_blah_key.pub')
      .and_return 'ssh-rsa blah'

    expect(chef_run.node.run_state.key?('public_key/blah_blah_key')).to eq false

    block = chef_run.ruby_block('get the \'blah blah key\' key')
    block.old_run_action(:run)

    expect(chef_run.node.run_state['public_key/blah_blah_key']).to eq 'ssh-rsa blah'
  end

However, as mentioned in Chef 13 Upgrade: Testing ruby_blocks with ChefSpec, the block.old_run_action method has been removed in Chef 13. Fortunately the ruby_block's block property is of type Proc ([Proc#call][proc-call]), we which means we can perform the following minor change, and still retain functionality:

 # spec
 it ' ... ' do
   ...
   block = chef_run.ruby_block('get the \'blah blah key\' key')
-  block.old_run_action(:run)
+  block.block.call

   expect(chef_run.node.run_state['public_key/blah_blah_key']).to eq 'ssh-rsa blah'
 end

Extracting to a Helper Class

With the code now working in Chef 13, I started to think about how ideal it was to have this method only tested within the block itself.

For instance, let's assume this block is duplicated in three different recipes. This means that we'd have three tests in three places, and any changes to the block would have to be propagated across recipes, updating tests as we go.

A better way to do this would be to extract the code into its own library function so we can unit test it in isolation:

# libraries/ruby_block_helper.rb
class Chef
  class Recipe
    class RubyBlockHelper
      def self.run_state_public_key(node, username, safe_key_name)
        node.run_state["public_key/#{safe_key_name}"] = \
          ::File.read("#{node['etc']['passwd'][username]['dir']}/.ssh/#{safe_key_name}.pub")
        node
      end
    end
  end
end

# spec/unit/libraries/ruby_block_helper_spec.rb
describe Chef::Recipe::RubyBlockHelper do
  context '#run_state_public_key' do
    it 'reads the public key from disk' do
      node = Chef::Node.new
      username = 'testuser'
      safe_key_name = 'fakename'

      node.automatic['etc']['passwd']['testuser']['dir'] = '/srv/wobble'

      allow(::File).to receive(:read)
        .and_call_original
      allow(::File).to receive(:read)
        .with('/srv/wobble/.ssh/fakename.pub')
        .and_return 'ssh-wibble hello'

      node_out = Chef::Recipe::RubyBlockHelper.run_state_public_key(node, username, safe_key_name)
      expect(node_out.run_state['public_key/fakename']).to eq 'ssh-wibble hello'
    end
  end
end

This then lets us change our ruby_block's implementation to simply call that method:

   ruby_block "get the '#{key_name}' key" do
     block do
-      node.run_state["public_key/#{safe_key_name}"] = ::File.read("#{node['etc']['passwd'][username]['dir']}/.ssh/#{safe_key_name}.pub")
+      Chef::Recipe::RubyBlockHelper.run_state_public_key(node, username, safe_key_name)
     end
   end

This gives us a much cleaner interface, and we can repeat this method call in many ruby_blocks and know it's working the same way.

That being said, our test hasn't been updated to check that the method was called - this verification can help us confirm that the implementation in each ruby_block remains correct:

   # spec
   it ' ... ' do
    ...
+   # ensure we're correctly calling the helper
+   allow(Chef::Recipe::RubyBlockHelper).to receive(:run_state_public_key)
+     .with(any_args)
+     .and_raise 'call to run_state_public_key not matched'
+   allow(Chef::Recipe::RubyBlockHelper).to receive(:run_state_public_key)
+     .with(any_args, 'www-spectatdesigns-co-uk', 'blah_blah_key')
+     .and_call_original

    block = chef_run.ruby_block('get the \'blah blah key\' key')
    block.old_run_action(:run)

    expect(chef_run.node.run_state['public_key/blah_blah_key']).to eq 'ssh-rsa blah'
   end

This ensures that we'll defer to the expected implementation if we're calling it with the correct arguments, and in the case we're not calling it with expected arguments, we'll break the test by raising an error.

*****

Written by Jamie Tanna on 07 March 2018, and last updated on 30 April 2018.

Content for this article is shared under the terms of the Creative Commons Attribution Non Commercial Share Alike 4.0 International, and code is shared under Apache License 2.0.

Tags

Categories