How to test a function that yields a block with Minitest and Rspec

Apr 26, 2016 - 5 min read
How to test a function that yields a block with Minitest and Rspec
Share

Have you ever tried writing a test for a function that takes a block and does something to that block? I recently had to do that same thing and found there was not much written about it so in this post I’m going to fix that, I’m going to show you how to create expectations on block arguments.

I’ve recently stumbled upon an interesting piece of code that gave me a hard time coming up with a good testing strategy. It was a library called Net::SFTP, a library which you can use to transfer files over an SSH connection.

So the problem with this library is the fact that the way you are supposed to use it is like this:

require 'net/sftp'

Net::SFTP.start('host', 'username', :password => 'password') do |sftp|
  sftp.upload!("/path/to/local", "/path/to/remote")
end

That’s all fine until you try to write a test for it, at which point, things get a little crazy.

So the way I would go about testing any external dependencies is to create an expectation about the fact that our code is delegating the call to the external library (Net::SFTP‘s upload! method in this case), passing in the required arguments.

What that is saying is “I only want to test the code that I write, and I trust that the library has been tested by its developers”.

Let’s see block yielding in action

So how do we go about testing the code in the example above? Examples tell a better story than words so I’ll first show you the code and we’ll examine it afterward.

First, let’s write the code that is making use of the Net::SFTP library.

# example.rb
require 'net/sftp'

class MyFiles
  def self.upload(host, username, password)
    Net::SFTP.start(host, username, password: password) do |sftp|
      sftp.upload!("./example.txt", "~/example.txt")
    end
  end
end

A test with a block expectation

And here comes the test. Note that this test is using Minitest and doesn’t require Rails to work, it’s just plain ruby. By the way, the code is up on GitHub at blog_testing_blocks.

require 'minitest/autorun'
require_relative './example'

describe MyFiles do
  describe '.upload' do
    it "invokes #upload! on the Net::SFTP's block argument" do
      conn_info = lambda do |host, user, opt|
        # We're making sure that the correct arguments are passed along
        assert_equal 'mixandgo.com', host
        assert_equal 'cezar', user
        assert_equal ({ password: 'secret' }), opt
      end

      # We're mocking the block argument (`sftp` in our case)
      sftp_mock = Minitest::Mock.new

      # We want to know that the implementation is calling the `upload!`
      # method with ['./example.txt', '~/example.txt'] as arguments and
      # returns true
      sftp_mock.expect :upload!, true, ['./example.txt', '~/example.txt']

      # The third argument to the `stub` method stands for the block
      # argument (`sftp` in our case).
      Net::SFTP.stub :start, conn_info, sftp_mock do
        # For the duration of this block, the `start` method on the
        # `Net::SFTP` class is stubbed and the block argument it
        # receives is our `sftp_mock`
        MyFiles.upload('mixandgo.com', 'cezar', 'secret')
      end

      sftp_mock.verify
    end
  end
end

To run the test you do:

$ ruby -Ilib:test ./example_test.rb

Finished in 0.001593s, 627.7247 runs/s, 1883.1742 assertions/s.

1 runs, 3 assertions, 0 failures, 0 errors, 0 skips

Let’s see what we’ve done here

Given we don’t want to test that the third party code does what it says it does and only test the fact that we are calling its upload! method with the arguments we want (an array with two values, “./example.txt”, “~/example.txt”), we will stub the Net::SFTP‘s .start method and mock the block argument.

One thing to notice before we look at the mock is that on the stub we also specify the arguments it receives so what that translates to is “only stub calls to the .start method that also provide these exact arguments (the ones we supply as conn_info), I want to stub any other calls to .start, just the ones with these exact arguments”.

A nice thing about the .stub method (provided by Minitest) is the fact that it takes a third argument which will be passed along as the block argument(s) if stubbed method yields a block.

So in our example test we’re creating a mock object called sftp_mock and expect it to receive a call to .upload!, with the ['./example.txt', '~/example.txt'] array as its arguments.

How do you write a block expectation in RSpec?

I realize that Minitest might not be your test framework of choice so I’ve added another example for you to see how that same test might look like in RSpec. If you want to run this on your machine, make sure you’ve installed the rspec gem first (you can do that with gem install rspec from the same folder where you have the example files).

# This is the RSpec version of the Minitest example
require_relative './example'

RSpec.describe MyFiles do
  describe '.upload' do
    it "invokes #upload! on the Net::SFTP's block argument" do
      conn_info = [ 'mixandgo.com', 'cezar', { password: 'secret' } ]

      # We're mocking the argument (`sftp` in our case) that is sent
      # to the block.
      sftp_mock = double.as_null_object
      allow(Net::SFTP).to receive(:start).with(*conn_info).
        and_yield(sftp_mock)

      # We want to know that the implementation is calling the
      # `upload!` method with these exact arguments.
      expect(sftp_mock).to receive(:upload!).
        with('./example.txt', '~/example.txt')

      MyFiles.upload('mixandgo.com', 'cezar', 'secret')
    end
  end
end

Again, you don’t have to type everything, you can just head over to the GitHub repo and get the code from there.

Conclusion

To be honest, I don’t really like the way that code looks like and the fact that the tests are so complex, but it all works and gets the job done. I would much rather use a more functional approach if possible but that’s a whole different topic.

So I hope you’ve learned something new today and feel free to send me your thoughts on the subject.

12 Project Ideas
Cezar Halmagean
Software development consultant with over a decade of experience in helping growing companies scale large Ruby on Rails applications. Has written about the process of building Ruby on Rails applications in RubyWeekly, SemaphoreCI, and Foundr.