RSpec's #and_yield makes testing methods that takes a block argument super easy.
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.