Search code examples
ruby-on-railsunit-testingrspecstub

Rspec: Tempfile subclass of File get intercepted by File stub request


I'm doing a stub request to File but since I call Tempfile (that is a subclass of File) before calling File, Tempfile is intercepting the stub I define.

Model:

def download_file
  #...
  begin
    tempfile = Tempfile.new([upload_file_name, '.csv'])
    File.open(tempfile, 'wb') { |f| f.write(result.body.to_s) }
    tempfile.path
  rescue Errno::ENOENT => e
    puts "Error writing to file: #{e.message}"
    e
  end
end

Rspec example:

it 'could not write to tempfile, so the report is created but without content' do
  allow(File).to receive(:open).and_return Errno::ENOENT
  response_code = post @url, params: { file_ids: [@file.id] }
  expect(response_code).to eql(200)
  assert_requested :get, /#{@s3_domain}.*/, body: @body, times: 1

  report = @file.frictionless_report
  expect(report.report).to eq(nil)
end

Error:

tempfile in the line

tempfile = Tempfile.new([upload_file_name, '.csv'])

receives Errno::ENOENT, indicating that the stub is going to Tempfile instead of to File.

How can I define the stub to go to File instead of to Tempfile?


Solution

  • There's no need to reopen a Tempfile, it's already open and delegates to File.

    def download_file
      tempfile = Tempfile.new([upload_file_name, '.csv'])
      tempfile.write(result.body.to_s)
      tempfile.path
    # A method has an implicit begin.
    rescue Errno::ENOENT => e
      puts "Error writing to file: #{e.message}"
      e
    end
    

    Then you can mock just Tempfile.new. Note that exceptions are raised, not returned.

    it 'could not write to tempfile, so the report is created but without content' do
      # Exceptions are raised, not returned.
      allow(Tempfile).to receive(:new)
        .and_raise Errno::ENOENT
    
      response_code = post @url, params: { file_ids: [@file.id] }
      expect(response_code).to eql(200)
      assert_requested :get, /#{@s3_domain}.*/, body: @body, times: 1
    
      report = @file.frictionless_report
      expect(report.report).to eq(nil)
    end
    

    However, this remains fragile glass-box testing. Your test has knowledge of the implementation, if the implementation changes the test gives a false negative. And it still has to hope mocking Tempfile.new doesn't break something else.

    Instead, extract temp file creation from download_file.

    private def new_temp_file_for_upload
      Tempfile.new([upload_file_name, '.csv'])
    end
    
    def download_file
      tempfile = new_temp_file_for_upload
      tempfile.write(result.body.to_s)
      tempfile.path
    rescue Errno::ENOENT => e
      puts "Error writing to file: #{e.message}"
      e
    end
    

    Now the mocking can be targeted to a specific method in a specific object. And we can apply some good rspec patterns.

    context 'when the Tempfile cannot be created' do
      # Here I'm assuming download_file is part of the Controller being tested.
      before do
        allow(@controller).to receive(:new_temp_file_for_upload)
          .and_raise Errno::ENOENT
      end
    
      it 'creates the report without content' do
        post @url, params: { file_ids: [@file.id] }
    
        expect(response).to have_http_status(:success)
        assert_requested :get, /#{@s3_domain}.*/, body: @body, times: 1
    
        report = @file.frictionless_report
        expect(report.report).to be nil
      end
    end
    

    Note: returning "success" and an empty report after an internal failure is probably incorrect. It should return a 5xx error so the user knows there was a failure without having to look at the content.

    download_file is doing too many things. It's both downloading a file and deciding what to do with a specific error. It should just download the file. Let something higher up in the call stack decide what to do with the exception. Methods which do one thing are simpler and more flexible and easier to test and less buggy.

    private def new_temp_file_for_upload
      Tempfile.new([upload_file_name, '.csv'])
    end
    
    def download_file
      tempfile = new_temp_file_for_upload
      tempfile.write(result.body.to_s)
      tempfile.path
    end
    
    context 'when the download fails' do
      before do
        allow(@controller).to receive(:download_file)
          .and_raise "krunch!"
      end
    
      it 'responds with an error' do
        post @url, params: { file_ids: [@file.id] }
    
        expect(response).to have_http_status(:error)
      end
    end
    

    Note that no specific error is needed. It's enough that download_file raises an exception. This test now has no knowledge of the internals beyond knowing that download_file is called.