Test Driving Module Methods

Recently I had the pleasure and frustration of working the net-sftp gem for Ruby. Pleasure because it’s a well written library, with an easy to use syntax that looks something like this:

1 Net::SFTP.start( 'localhost', :registry_options => { :logs => { :levels => { "sftp.*" => :debug } } }) do |sftp|
2   sftp.put_file "test.data", "temp/blah.data"
3   puts "getting remote file to local location..."
4   sftp.get_file "temp/blah.data", "new.data"
5 end

The above is just a shortened version from one of the examples in the Gem itself. It’s simple to use and easy to read. Having written similar code in C++, for Windows no less, I can really appreciate how quickly this can get an FTP application off the ground.

The frustration came when I went to test drive this guy. Net::SFTP.start is a module method, not a class member. I can’t stub it in the traditional way using the RSpec stub! command or using should_receive.

On top of that it passes back a block, which needs to be tested to make sure it’s being called correctly. After a few shots at mocking it out Paul and I test drove it with an actual FTP server.

In the short term that was necessary anyway, as a hazard of frequent mocking can be that you are only testing how well you read the API. You see that when the tests pass and the first shot at actually running the code blows up.

In the long term the customer asked for a few new small features, changing directories and what not, and I really want to get this under long-term test to do that. So how do we do it?

Well we could stop using the .start command entirely. We could pass in a mock Net::SFTP object and test it, making sure to close it manually. Unfortunately that eliminates the clean code we see above, and if possible I’d like to keep it. The solution is to intercept the start method.

The first thing I do is monkey patch the code like so:

1 module Net ; module SFTP
2   def start( *args, &block )
3   end
4   module_function :start
5 end ; end 
6 context "My Context" do 

I’ve put it before the context, to make sure it’s redefined before the object I’m testing is created. Next we need to expose our mock objects in the context to the monkey patch.

This isn’t done with traditional writers and readers, because that would require finding the specific specification running for each time through the monkey patched start.

Instead we make our mocks class members in the setup method, and create a class method in the context to retrieve the variables. The class method looks like this:

1 def Spec.get_mock_ftp_objects
2   return @@mock_starter, @@mock_session
3 end

This reveals a bit of the underworkings of RSpec. Each block in the context block is turned into a class method using class_eval, as part of the Spec object.

Making the method static allowed this new monkey patched method:

 1 module Net
 2   module SFTP
 3     def start( *args, &block )
 4       @mock_starter, @mock_session = Spec.get_mock_ftp_objects
 5       @mock_starter.start args[0], args[1], args[2]
 6       yield @mock_session
 7     end
 8     module_function :start
 9   end
10 end

The code gets the two mock objects via our new method. Isn’t it grand how Ruby lets you return multiple objects? The call to start allows me to make sure that the arguments passed to the real start are correct.

The real interesting call is the yield. By yielding the mock back to the object it will replace the sftp in the original code. Now I can test it! In fact I’ve already realized a bug in my code (in stopping) just by the process of doing this.

I love it when a plan comes together. The final code example is here, I ended up extracting out a new class, so the names have changed. This one tests both the starting object and the block yielded:

 1 require 'net/sftp'
 2 require File.expand_path(File.dirname(__FILE__) + "/ftp_client")
 4 module Net
 5   module SFTP
 6     def start( *args, &block )
 7       @mock_starter, @mock_session = Spec.get_mock_ftp_objects
 8       @mock_starter.start args[0], args[1], args[2]
 9       yield @mock_session
10     end
11     module_function :start
12   end
13 end
15 context "FTP Client" do  
16   setup do
17     @client = FtpClient.new("test_server", "user", "password", "directory")
18     @@mock_starter = mock('mock_starter')
19     @@mock_session = mock('mock_session')
20     @@mock_session.stub!(:opendir)
21     @@mock_session.stub!(:close_handle)
22   end
24   specify "makes ftp connection, to proper place" do
25     @@mock_starter.should_receive(:start).with("test_server", "user", "password")
26     @client.read_from_server
27   end
29   specify "changes to ftp_directory, better close that handle" do
30     @@mock_starter.should_receive(:start).with("test_server", "user", "password")
31     @@mock_session.should_receive(:opendir).with("directory").and_return("fake handle")
32     @@mock_session.should_receive(:close_handle).with("fake handle")
33     @client.read_from_server
34   end
36   def Spec.get_mock_ftp_objects
37     return @@mock_starter, @@mock_session
38   end
39 end

Maybe this isn’t the best way to do this, but I like it. I’m looking forward to comments.

Eric Smith, Software Crafter

Eric Smith is a Principal Crafter at 8th Light Chicago.

Interested in 8th Light's services? Let's talk.

Contact Us