2

Consider the following methods:

  class MyClass
    def initialize
    end

    def will_throw
      print_message_to_stderr
      throw MyError
    end

    def print_message_to_stderr
      STDERR.puts 'Boom!'
    end
  end

I would like to test that will_throw does output a message to stderr first:

it 'outputs to stderr' do
  instance = MyClass.new

  expect { instance.will_throw }.to output('Boom!').to_stderr
end

This, however, doesn't succeed because the exception is thrown and the test suite blows up.

On the other hand, this is always green no matter what, and even when the test should fail:

it 'outputs to stderr' do
  instance = MyClass.new

  expect { instance.will_throw }.to output('Boom!').to_stderr
  rescue MyError
  end
end

How can I test this properly (using RSpec and plain Ruby)?

springloaded
  • 1,079
  • 2
  • 13
  • 23

1 Answers1

0

RSpec cannot catch STDERR nor STDOUT.

Note: to_stdout and to_stderr work by temporarily replacing $stdout or $stderr, so they're not able to intercept stream output that explicitly uses STDOUT/STDERR or that uses a reference to $stdout/$stderr that was stored before the matcher is used.

STDERR is a constant and cannot (really should not) be changed. Simplest solution is to use $stderr instead. Better yet, use warn to make your intentions plain and allow warnings to be redirected.

Second problem is throw and raise are not equivalent. throw is for control flow, raise is for exceptions. throw is like Ruby's goto. Use raise instead.

class MyClass
  # You don't need an empty initialize, it is inherited from Object.

  def will_raise
    print_message_to_stderr
    raise MyError
  end

  def print_message_to_stderr
    warn 'Boom!'
  end
end

class MyError < RuntimeError
end

RSpec.describe MyClass do
  it 'outputs to stderr and raises MyError' do
    instance = described_class.new

    expect {
      instance.will_raise
    }.to output("Boom!\n").to_stderr
    .and raise_error(MyError)
  end
end

Since your "warning" immediately precedes an exception, it's not really a warning, it's an error message. If you make it part of the Exception it will make handling errors much simpler. Do this by providing a default message. If the message is complex, you can override #message.

Now the error and message can be caught and controlled as one unit. The code and tests are much simpler.

class MyClass
  def will_raise
    raise MyError
  end
end

class MyError < RuntimeError
  def initialize(message = "Boom!")
    super
  end
end

RSpec.describe MyClass do
  it 'raises MyError' do
    instance = described_class.new

    expect {
      instance.will_raise
    }.to raise_error(MyError, "Boom!")
  end
end

True warnings and informational messages are better done via Logger. Logged messages are easier to control and redirect.

require 'logger'

class MyClass
  def logger
    @logger ||= Logger.new(STDERR)
  end

  def will_raise
    logger.warn("Lookout!")
    raise MyError
  end
end

class MyError < RuntimeError
  def initialize(message = "Boom!")
    super
  end
end

One generally does not test log messages, they're usually turned off during testing, but if you really wanted to you can check using a message expectation and with to specify its arguments.

RSpec.describe MyClass do
  it 'raises MyError and logs a warning' do
    instance = described_class.new

    expect(instance.logger).to receive(:warn)
      .with("Lookout!")

    expect {
      instance.will_raise
    }.to raise_error(MyError, "Boom!")
  end
end

Generally you don't want to be trapping output if you can avoid it. You want to check method calls instead.

Schwern
  • 153,029
  • 25
  • 195
  • 336
  • Thank you. That particular gem is a command line tool that is rather limited in scope. Would a logger still make more sense in that case? I took inspiration from other tools and they usually output their messages to stderr before closing. I wanted to avoid leaving the user confused with a ruby stack trace and error. – springloaded Nov 25 '19 at 08:09
  • 1
    @springloaded Logger comes with Ruby, no reason not to use it. If nothing else Logger lets you easily provide verbosity levels. – Schwern Nov 25 '19 at 16:46