3

It's fairly common in Ruby for methods that take blocks to look like this:

class File
  def open(path, mode)
    perform_some_setup
    yield
  ensure
    do_some_teardown
  end
end

It's also fairly idiomatic for a method to look like this:

def frobnicate
  File.open('/path/to/something', 'r') do |f|
    f.grep(/foo/).first
  end
end

I want to write a spec for this that doesn't hit the filesystem, which ensures it pulls the right word out of the file, something like:

describe 'frobnicate' do
  it 'returns the first line containing the substring foo' do
    File.expects(:open).yields(StringIO.new(<<EOF))
      not this line
      foo bar baz
      not this line either
    EOF
    expect(frobnicate).to match(/foo bar baz/)  
  end
end

The problem here is that, by mocking out the call to File.open, I've also removed its return value, which means that frobnicate will return nil. If I were to add something like File.returns('foo bar baz') to the chain, however, I'd end up with a test that doesn't actually hit any of the code I'm interested in; the contents of the block in frobnicate could do anything and the test would still pass.

How might I appropriately test my frobnicate method without hitting the filesystem? I'm not particularly attached to any particular testing framework, so if your answer is "use this awesome gem that'll do it for you" then I'm OK with that.

Vega
  • 27,856
  • 27
  • 95
  • 103
ymbirtt
  • 1,481
  • 2
  • 13
  • 24

2 Answers2

2

It seems like you just need to mock the call to File a little differently. I was getting syntax errors running your code as-is, so I'm not sure what version of RSpec you're on, but if you're on 3.x this will do the job:

frobnicate_spec.rb

gem 'rspec', '~> 3.4.0'
require 'rspec/autorun'

RSpec.configure do |config|
  config.mock_with :rspec
end

def frobnicate
  File.open('/path/to/something', 'r') do |f|
    f.grep(/foo/).first
  end
end

RSpec.describe 'frobnicate' do
  it 'returns the first line containing the substring foo' do
    allow(File).to receive(:open).and_call_original
    allow(File).to receive(:open).and_yield StringIO.new <<-EOF
      not this line
      foo bar baz
      not this line either
    EOF
    expect(frobnicate).to match(/foo bar baz/)
  end
end

Invoke with ruby frobnicate_spec.rb so we can use a specified RSpec version.

Source: RSpec Mocks expecting messages and yielding responses

ymbirtt
  • 1,481
  • 2
  • 13
  • 24
flanger001
  • 757
  • 6
  • 16
  • Ah, sorry about the syntax error; I'd cut a lot of the original code down to get a MWE. Seems like your solution goes via rspec-mocks, whilst what I'd written goes via Mocha. Unfortunately, I'm getting an `undefined method allow` when I use your solution; I'm on rspec 3.4.1. – ymbirtt Jul 12 '16 at 14:01
  • What Ruby version are you on? – flanger001 Jul 12 '16 at 14:03
  • Disregard - I updated the test setup so to avoid the collisions with Mocha. – flanger001 Jul 12 '16 at 14:21
  • Having just tried it with rubies 1.9.3, 2.0.0 and 2.1.2, I can definitely confirm that the problem was the config.mock_with. Once that was in place, I could mock out calls to open on that specific file and achieve exactly what I wanted. Thanks for your help! – ymbirtt Jul 12 '16 at 14:30
1

Using minitest it could be done like I post below. I have added the whole runnable file, so you can test it from the command line with ruby -Ilib:test test_file.rb:

def frobnicate
  found_string = nil
  File.open('/path/to/something', 'r') do |f|
    found_string = f.grep(/foo/).first
  end
  found_string
end

class FrabnicateTest < Minitest::Test
  def test_it_works
    mock_file = StringIO.new(%(
      not this line
      foo bar baz
      not hthis line either
    ))
    search_result = nil
    File.stub(:open, nil, mock_file) do
      search_result = frobnicate
    end
    assert_match(/foo bar baz/, search_result)
  end
end
chipairon
  • 2,031
  • 2
  • 19
  • 21
  • Something I really want to avoid is editing the definition of `frobnicate` just so that some tests will run against it. The tests serve the code, not the other way around. – ymbirtt Jul 12 '16 at 14:02
  • Difficulty in testing is a smell telling us that something should be done better on the code under test. A selling point of TDD for me is that tests encourage us to write better code. Your code will be better if it did **not hardcode the dependency** on the File class. You can trust the creator of minitest better than me: https://github.com/seattlerb/minitest/issues/499#issuecomment-73341651or search the internet about hard coding dependencies. Then the code would be pleasantly simple to work with (and also test). – chipairon Jul 12 '16 at 17:18