Stubbing Out Block Arguments in RSpec

RSpec's #and_yield makes testing methods that takes a block argument super easy.

Rspec

Methods in popular Ruby libraries and gems (ssh-sftp, File, etc.) take block arguments to do their work. But when testing, we don't care about testing whether a library works, we only want to test it in the scope of our application. Here's where RSpec's #and_yield shines.



Consider the following code using the net-ssh/net-sftp gem that uploads a file to an SFTP server via an SSH connection:

# app/utils/sftp_util.rb

class SftpUtil
  attr_reader :host, :user, :options

  def initialize(host, user, options = {})
      @host  = host
      @user  = user
      @options  = options
  end

  def upload(local_fname, remote_fname)
       Net::SSH.start(host, user, options) do |ssh|
         ssh.sftp.upload!(local_fname, remote_fname)
      end
  end
end



Awesome, now here's on we can test that with RSpec's #and_yield. Notice, how we're creating nested doubles which mimick a chain of method invocations.

# spec/utils/sftp_util_spec.rb

describe SftpUtil do 
  let(:sftp_config) { {keys: [ '123xyz' ], port: 9876} }
  let(:dir_double) { double(:dir, entries: ['file-one.txt', 'file-two.txt']) }
  let(:sftp_double) { double(:sftp, upload!: true, download!: true, dir: dir_double) }
  let(:ssh_double) { double(:ssh, sftp: sftp_double) }
  let(:local_file) { 'some-local-file.txt' }
  let(:remote_file) { 'some-remote-file.txt' }
  subject { SftpUtil.new(local_file, remote_file, 'host', 'user', sftp_config) }

  describe '#upload' do
    it 'uploads local files to a remote location via ssh' do
      expect(Net::SSH).to receive(:start).with('host', 'user', sftp_config).and_yield(ssh_double)
      expect(sftp_double).to receive(:upload!).with(local_file, remote_file)

      SftpUtil.upload(local_file, remote_file)
    end
  end
end



Awesome, pretty gnarly. But where this gets really useful is when we want to extend the SftpUtil class and add a #download function too. This prompts a little refactoring to DRY up our code. So let's wrap our Net::SSH.start(host, user, options, &block) with a method that takes block argument as well.

# app/utils/sftp_util.rb

class SftpUtil
  attr_reader :host, :user, :options

  def initialize(host, user, options = {})
      @host  = host
      @user  = user
      @options  = options
  end

  def upload(local_fname, remote_fname)
     connection do |ssh|
         ssh.sftp.upload!(local_fname, remote_fname)
      end
  end

  def download(remote_fname, local_fname)
     connection do |ssh|
         ssh.sftp.download!(remote_fname, local_fname)
      end
  end

  private

  def connection(&block)
       Net::SSH.start(host, user, options) do |ssh|
         yield(ssh)
      end
  end 
end



Now, we have a nice wrapper method called #connection which yields a block that is passed as an argument. Now all we have to do is refactor our tests a little to accomodate the change.

# spec/utils/sftp_util_spec.rb

describe SftpUtil do 
  let(:sftp_config) { {keys: [ '123xyz' ], port: 9876} }
  let(:dir_double) { double(:dir, entries: ['file-one.txt', 'file-two.txt']) }
  let(:sftp_double) { double(:sftp, upload!: true, download!: true, dir: dir_double) }
  let(:ssh_double) { double(:ssh, sftp: sftp_double) }
  let(:local_file) { 'some-local-file.txt' }
  let(:remote_file) { 'some-remote-file.txt' }
  subject { SftpUtil.new(local_file, remote_file, 'host', 'user', sftp_config) }

  def stub_connection_and_ensure(message, input_file, output_file)
    expect(Net::SSH).to receive(:start).with('host', 'user', sftp_config).and_yield(ssh_double)

    expect(sftp_double).to receive(message).with(input_file, output_file)
  end

  describe '#upload' do
    it 'uploads local files to a remote location via ssh' do
      stub_connection_and_ensure(:upload!, local_file, remote_file)

      subject.upload(local_file, remote_file)
    end
  end

  describe '#download' do
    it 'downloads a remote file to a local file via ssh' do
      stub_connection_and_ensure(:download!, remote_file, local_file)

      subject.download(remote_file, local_file)
    end
  end
end



That was pretty harmless, right? What's even nicer is that the library gets pushed down into a private method that's implicitly tested by the message expectations. All we're testing now is that we've chained the correct methods together and have successfully passed the correct arguments to the library. Boom, awesome sauce.