How to mock with RSpec an API that accepts and then calls block callbacks?

3k views Asked by At

I have this method:

def download_zip_file
  FileUtils.rm_rf(@zip_path) if @zip_path
  FileUtils.mkdir_p(@zip_path)
  downloaded_file = File.open(@zip_file, "wb")
  request = Typhoeus::Request.new(@feed, followlocation: true)
  request.on_body { |chunk| downloaded_file.write(chunk) }
  request.on_complete { |response| downloaded_file.close }
  request.run
end

It clears the zip_path, recreates it, opens a file for writing, then downloads the file from the @feed URL and writes to the downloaded file in chunks.

I'm wondering how to unit test it, mocking the actual request. Since it uses chunking via some blocks, it's a bit complicated.

I formerly had this code:

def download_feed_data_from_url
  response = Typhoeus.get(@feed, followlocation: true)
  raise(FeedNotFoundError, "Could not find feed at feed: #{@feed}. Response: #{response.inspect}") unless response.success?
  result = response.body
end

Which was easy to test (by mocking Typhoeus and providing a stub return):

context "testing feed downloading" do
  let(:feed) { "http://the.feed.url" }
  let(:response) { double(body: "some content", success?: true) }

  before
    allow(Typhoeus).to receive(:get).with(feed, followlocation:true).and_return(response)
  end

  # ... individual assertions, i.e. that Typhoeus is called, that it pulls the body content, etc.
end

So I'm wondering how to unit test the same kinds of things... i.e. that the the path is created, the file is saved, etc. while mocking Typhoeus. Since it's a 3rd party library, I don't need to test that it works, just that it's called correctly.

It's the chunking and on_body and on_complete that are confusing me (in terms of how to test it)

2

There are 2 answers

1
Dave Schweisguth On

The key is that you can give an RSpec allow or expect a block implementation. So stub on_body and on_complete to save the blocks you give them, and run to call those blocks:

it "writes to and closes the file" do
  downloaded_file = double
  expect(downloaded_file).to receive(:write)
  expect(downloaded_file).to receive(:close)
  allow(File).to receive(:open).and_return(downloaded_file)

  request = double
  allow(request).to receive(:on_body) { |&block| @on_body = block }
  allow(request).to receive(:on_complete) { |&block| @on_complete = block }
  allow(request).to receive(:run) do
    @on_body.call "chunk"
    @on_complete.call nil
  end
  allow(Typhoeus::Request).to receive(:new).and_return(request)

  download_zip_file
end

I didn't verify any arguments, but you can add withs to do that. I also left out stubbing and mocking the FileUtils calls, since those are easy.

The interesting part of this question is how to stub an API like this in RSpec, so that's what I addressed. However, I would probably write an acceptance test (Cucumber or RSpec feature spec) first that exercises all of the code end to end, then write unit tests to test error handling and such.

1
SunnyMagadan On

The main responsibility of your method is download of the zip file into a specific folder in the local file system. It doesn't matter how you can perform this. The result is matters here.

If you want to check that your method does download properly then you should stub network request then call download_zip_file method then check if a file was created at corresponding path and its contents match to the stubbed response body.

Typhoeus has support of request stubbing: https://github.com/typhoeus/typhoeus/tree/d9e6dce92a04754a2276c94393dad0f3a5c06bdd#direct-stubbing Alternatively, you could use Webmock for the same purpose. It has support of Typhoeus requests stubbing: https://github.com/bblimke/webmock

Example:

it "downloads file" do
  zip_path = "tmp/downloads"
  zip_file = "filename.zip"
  downloaded_file_path = "#{zip_path}/#{zip_file}"
  feed = "http://www.colorado.edu/conflict/peace/download/peace.zip"
  zip_file_content = "some zip file content"

  response = Typhoeus::Response.new(code: 200, body: zip_file_content)
  Typhoeus.stub(feed).and_return(response)

  Downloader.new(zip_path, zip_file, feed).download_zip_file

  expect(File.exists?(downloaded_file_path)).to eq(true)
  expect(File.read(downloaded_file_path)).to eq(zip_file_content)
end

Moreover, I would suggest to use in-memory fake file system for your tests, which create files and folders, in order to not pollute local file system with a garbage. Memfs is a good gem for this. https://github.com/simonc/memfs It's easy to add it to your tests:

before do
  MemFs.activate!
end

after do
  MemFs.deactivate!
end

After that your tests won't create any local files, but functionality will remain the same.