Testing file uploads in Ruby on Rails with RSpec

While working on my latest project,I wanted to drive the design of my file upload code using Behavior Driven Development using Webrat and RSpec. For most people, I would recommend using a plugin like Paperclip rather than reinvent the wheel, but if you’re interested in seeing how to use RSpec to drive the design of file upload code, read on.

I’m a big fan of Behavior Driven Development and all the code you see below was driven by a customer’s acceptance test for a vital business feature. In order to keep this simple, I’m just going to describe how I used RSpec to drive the file upload code, but feel free to read the RSpec book to get more information on BDD with Ruby on Rails.

Designing the Document class

Since all our business rules and logic belongs to our domain model, the Document class has to be smart. It should know how to store uploaded files on the server, how to handle file name clashes etc. The controller should be able to simply call a method on this class to store an uploaded file correctly. This is the RSpec example for the save_upload method in the Document class:

it "should be able to save an upload with an unique name generated by using a timestamp" do
  # As of Rails 2.3.2, the framework passes an UploadedStringIO to the controller when uploading files
  # I'm creating a mock that I can pass to my save_upload method on my Document class.
  # The save_upload only requires that the passed in object respond to original_filename and read.
  uploaded_file = mock(ActionController::UploadedStringIO).as_null_object
  uploaded_file.stub!(:original_filename).and_return('test.doc')
  uploaded_file.stub!(:read).and_return("Some content")

  document = Document.new

  document.save_upload(uploaded_file)
  document.original_filename.should == "test.doc"
  # To prevent file name clashes, the saved file's new name is prepended with a timestamp
  document.new_filename.should =~ /\d{2}\d{2}\d{4}\d{2}\d{2}\d{2}-test.doc/
  # Finally, does the file exist on the file system and if so are the contents of this file correct?
  File.exist?("#{Rails.root}/public/system/documents/#{document.new_filename}").should == true
  File.open("#{Rails.root}/public/system/documents/#{document.new_filename}", "r") { |f| f.read.should == uploaded_file.read }
end

Implementing the Document class’s save_upload method

Once we’ve got the RSpec example, writing the actual code to make the example pass is pretty simple.

def save_upload(upload)
  self.original_filename = sanitize_file_name(upload.original_filename)
  self.new_filename = unique_filename
  # Write the file
  File.open(absolute_path, "wb") { |f| f.write(upload.read) }
end

private
  def absolute_path
    File.join(Rails.root, 'public/system/documents', self.new_filename)
  end

  def sanitize_file_name(filename)
    # Internet explorer returns the complete path to the file, instead of just the file name that was uploaded.
    # File.basename gives us just the filename and fixes the above issue with Internet Explorer.
    # Replace everything other than alphanumeric characters, periods or underscores with underscores to ensure
    # there are no illegal characters
    File.basename(filename).gsub(/[^\w\.\_]/, '_')
  end

  def unique_filename
    # Prepend a timestamp to the original filename to create a unique filename
    Time.now.strftime("%m%d%Y%H%M%S").to_s + "-#{self.original_filename}"
  end

Designing the controller code

Now that we’ve got all the business logic required for saving an uploaded file in the Document class, the actual code for the controller method is pretty simple. All we need to do is check that the document’s save_upload method is called with the correct parameter.

it "should save the uploaded document" do
  document = mock(Document).as_null_object

  Document.should_receive(:new).and_return(document)
  uploaded_file = mock(ActionController::UploadedStringIO).as_null_object
  params = { "id" => "1", "document" => uploaded_file }
  document.should_receive(:save_upload).with(uploaded_file)
  post :save_file, params
end

Implementing the controller code

Getting this RSpec example to pass is pretty simple. The controller code simply creates a new Document object and then calls the save_upload method with the correct form parameter.

  def save_file
    if params[:document] != nil
      document = Document.new
      document.save_upload(params[:document])
    end
  end

Respond to this post