Test Driving Module Methods

Test Driving Module Methods

Eric Smith
Eric Smith

May 03, 2007

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:

1Net::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"

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:

1module Net ; module SFTP
2 def start( *args, &block )
3 end
4 module_function :start
5end ; end 
6context "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:

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

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:

 1module 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

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:

 1require 'net/sftp'
 2require File.expand_path(File.dirname(__FILE__) + "/ftp_client")
 4module 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
15context "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

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