Skip to content
This repository was archived by the owner on Nov 30, 2024. It is now read-only.

Handle errors in :suite hooks. #2316

Merged
merged 7 commits into from
Aug 29, 2016
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Bug Fixes:
conflicting keys if the value in the host group was inherited from
a parent group instead of being specified at that level.
(Myron Marston, #2307)
* Handle errors in `:suite` hooks and provided the same nicely formatted
output as errors that happen in examples. (Myron Marston, #2316)

### 3.5.2 / 2016-07-28
[Full Changelog](http://github.com/rspec/rspec-core/compare/v3.5.1...v3.5.2)
Expand Down
17 changes: 12 additions & 5 deletions lib/rspec/core/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1835,12 +1835,11 @@ def around(scope=nil, *meta, &block)
def with_suite_hooks
return yield if dry_run?

hook_context = SuiteHookContext.new
begin
run_hooks_with(@before_suite_hooks, hook_context)
run_suite_hooks("a `before(:suite)` hook", @before_suite_hooks)
yield
ensure
run_hooks_with(@after_suite_hooks, hook_context)
run_suite_hooks("an `after(:suite)` hook", @after_suite_hooks)
end
end

Expand Down Expand Up @@ -1880,8 +1879,16 @@ def handle_suite_hook(scope, meta)
yield
end

def run_hooks_with(hooks, hook_context)
hooks.each { |h| h.run(hook_context) }
def run_suite_hooks(hook_description, hooks)
context = SuiteHookContext.new(hook_description, reporter)

hooks.each do |hook|
begin
hook.run(context)
rescue Support::AllExceptionsExceptOnesWeMustNotRescue => ex
context.set_exception(ex)
end
end
end

def get_files_to_run(paths)
Expand Down
10 changes: 5 additions & 5 deletions lib/rspec/core/example.rb
Original file line number Diff line number Diff line change
Expand Up @@ -632,16 +632,16 @@ def issue_deprecation(_method_name, *_args)
# @private
# Provides an execution context for before/after :suite hooks.
class SuiteHookContext < Example
def initialize
super(AnonymousExampleGroup, "", {})
def initialize(hook_description, reporter)
super(AnonymousExampleGroup, hook_description, {})
@example_group_instance = AnonymousExampleGroup.new
@reporter = reporter
end

# rubocop:disable Style/AccessorMethodName

# To ensure we don't silence errors.
def set_exception(exception)
raise exception
reporter.notify_non_example_exception(exception, "An error occurred in #{description}.")
RSpec.world.wants_to_quit = true
end
# rubocop:enable Style/AccessorMethodName
end
Expand Down
3 changes: 2 additions & 1 deletion lib/rspec/core/formatters/exception_presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ def encoded_string(string)
end

def indent_lines(lines, failure_number)
alignment_basis = "#{' ' * @indentation}#{failure_number}) "
alignment_basis = ' ' * @indentation
alignment_basis << "#{failure_number}) " if failure_number
indentation = ' ' * alignment_basis.length

lines.each_with_index.map do |line, index|
Expand Down
1 change: 1 addition & 0 deletions lib/rspec/core/metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ def description_separator(parent_part, child_part)

def build_description_from(parent_description=nil, my_description=nil)
return parent_description.to_s unless my_description
return my_description.to_s if parent_description.to_s == ''
separator = description_separator(parent_description, my_description)
(parent_description.to_s + separator) << my_description.to_s
end
Expand Down
5 changes: 2 additions & 3 deletions lib/rspec/core/notifications.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,7 @@ def self.for(example)
FailedExampleNotification
end

exception_presenter = Formatters::ExceptionPresenter::Factory.new(example).build
klass.new(example, exception_presenter)
klass.new(example)
end

private_class_method :new
Expand Down Expand Up @@ -202,7 +201,7 @@ def fully_formatted(failure_number, colorizer=::RSpec::Core::Formatters::Console

private

def initialize(example, exception_presenter)
def initialize(example, exception_presenter=Formatters::ExceptionPresenter::Factory.new(example).build)
@exception_presenter = exception_presenter
super(example)
end
Expand Down
12 changes: 12 additions & 0 deletions lib/rspec/core/reporter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,18 @@ def deprecation(hash)
notify :deprecation, Notifications::DeprecationNotification.from_hash(hash)
end

# @private
# Provides a way to notify of an exception that is not tied to any
# particular exception (such as an exception encountered in a :suite hook).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First exception should read example I think?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed.

# Exceptions will be formatted the same way they normally are.
def notify_non_example_exception(exception, context_description)
@configuration.world.non_example_failure = true

example = Example.new(AnonymousExampleGroup, context_description, {})
presenter = Formatters::ExceptionPresenter.new(exception, example, :indentation => 0)
message presenter.fully_formatted(nil)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just double checking that presenter here isn't an extension point for plugins or anything like that? Passing nil is ok?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExceptionPresenter is tagged as # @private so it's not part of our public API. I pass nil here because usually we pass an integer (e.g. 7 if it's the 7th example that has failed) but there isn't a good way to get a number here so the error just doesn't have the 7) prefix. a1936ff was written to specifically allow this.

end

# @private
def finish
close_after do
Expand Down
9 changes: 6 additions & 3 deletions lib/rspec/core/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,17 @@ def setup(err, out)
# failed.
def run_specs(example_groups)
examples_count = @world.example_count(example_groups)
@configuration.reporter.report(examples_count) do |reporter|
success = @configuration.reporter.report(examples_count) do |reporter|
@configuration.with_suite_hooks do
if examples_count == 0 && @configuration.fail_if_no_examples
return @configuration.failure_exit_code
end
example_groups.map { |g| g.run(reporter) }.all? ? 0 : @configuration.failure_exit_code

example_groups.map { |g| g.run(reporter) }.all?
end
end
end && [email protected]_example_failure

success ? 0 : @configuration.failure_exit_code
end

private
Expand Down
9 changes: 9 additions & 0 deletions lib/rspec/core/world.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ class World
# Used internally to determine what to do when a SIGINT is received.
attr_accessor :wants_to_quit

# Used internally to signal that a failure outside of an example
# has occurred, and that therefore the exit status should indicate
# the run failed.
# @private
attr_accessor :non_example_failure

def initialize(configuration=RSpec.configuration)
@configuration = configuration
configuration.world = self
Expand Down Expand Up @@ -224,6 +230,9 @@ def fail_if_config_and_cli_options_invalid
# @private
# Provides a null implementation for initial use by configuration.
module Null
def self.non_example_failure; end
def self.non_example_failure=(_); end

def self.registered_example_group_files
[]
end
Expand Down
139 changes: 139 additions & 0 deletions spec/integration/suite_hooks_errors_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
require 'support/aruba_support'
require 'support/formatter_support'

RSpec.describe 'Suite hook errors' do
include_context "aruba support"
include FormatterSupport

let(:failure_exit_code) { rand(97) + 2 } # 2..99

if RSpec::Support::Ruby.jruby_9000?
let(:spec_line_suffix) { ":in `block in (root)'" }
elsif RSpec::Support::Ruby.jruby?
let(:spec_line_suffix) { ":in `(root)'" }
elsif RUBY_VERSION == "1.8.7"
let(:spec_line_suffix) { "" }
else
let(:spec_line_suffix) { ":in `block (2 levels) in <top (required)>'" }
end

before do
# get out of `aruba` sub-dir so that `filter_gems_from_backtrace 'aruba'`
# below does not filter out our spec file.
expect(dirs.pop).to eq "aruba"

clean_current_dir

RSpec.configure do |c|
c.filter_gems_from_backtrace "aruba"
c.backtrace_exclusion_patterns << %r{/rspec-core/spec/} << %r{rspec_with_simplecov}
c.failure_exit_code = failure_exit_code
end
end

def run_spec_expecting_non_zero(before_or_after)
write_file "the_spec.rb", "
RSpec.configure do |c|
c.#{before_or_after}(:suite) do
raise 'boom'
end
end

RSpec.describe do
it { }
end
"

run_command "the_spec.rb"
expect(last_cmd_exit_status).to eq(failure_exit_code)
normalize_durations(last_cmd_stdout)
end

it 'nicely formats errors in `before(:suite)` hooks and exits with non-zero' do
output = run_spec_expecting_non_zero(:before)
expect(output).to eq unindent(<<-EOS)

An error occurred in a `before(:suite)` hook.
Failure/Error: raise 'boom'

RuntimeError:
boom
# ./the_spec.rb:4#{spec_line_suffix}


Finished in n.nnnn seconds (files took n.nnnn seconds to load)
0 examples, 0 failures
EOS
end

it 'nicely formats errors in `after(:suite)` hooks and exits with non-zero' do
output = run_spec_expecting_non_zero(:after)
expect(output).to eq unindent(<<-EOS)
.
An error occurred in an `after(:suite)` hook.
Failure/Error: raise 'boom'

RuntimeError:
boom
# ./the_spec.rb:4#{spec_line_suffix}


Finished in n.nnnn seconds (files took n.nnnn seconds to load)
1 example, 0 failures
EOS
end

it 'nicely formats errors from multiple :suite hooks of both types and exits with non-zero' do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

huh this is interesting: we keep running hooks even if one fails.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a good point. In the follow-up commit I just pushed I addressed this, and made it behave the same as :example and :context hooks do with regard to an error happening in the first hook when there are multiple of them.

write_file "the_spec.rb", "
RSpec.configure do |c|
c.before(:suite) { raise 'before 1' }
c.before(:suite) { raise 'before 2' }
c.after(:suite) { raise 'after 1' }
c.after(:suite) { raise 'after 2' }
end

RSpec.describe do
it { }
end
"

run_command "the_spec.rb"
expect(last_cmd_exit_status).to eq(failure_exit_code)
output = normalize_durations(last_cmd_stdout)

expect(output).to eq unindent(<<-EOS)

An error occurred in a `before(:suite)` hook.
Failure/Error: c.before(:suite) { raise 'before 1' }

RuntimeError:
before 1
# ./the_spec.rb:3#{spec_line_suffix}

An error occurred in a `before(:suite)` hook.
Failure/Error: c.before(:suite) { raise 'before 2' }

RuntimeError:
before 2
# ./the_spec.rb:4#{spec_line_suffix}

An error occurred in an `after(:suite)` hook.
Failure/Error: c.after(:suite) { raise 'after 2' }

RuntimeError:
after 2
# ./the_spec.rb:6#{spec_line_suffix}

An error occurred in an `after(:suite)` hook.
Failure/Error: c.after(:suite) { raise 'after 1' }

RuntimeError:
after 1
# ./the_spec.rb:5#{spec_line_suffix}


Finished in n.nnnn seconds (files took n.nnnn seconds to load)
0 examples, 0 failures
EOS
end
end
12 changes: 12 additions & 0 deletions spec/rspec/core/formatters/exception_presenter_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ module RSpec::Core
EOS
end

it "prints no identifier when no number argument is given" do
expect(presenter.fully_formatted(nil)).to eq(<<-EOS.gsub(/^ +\|/, ''))
|
| Example
| Failure/Error: # The failure happened here!#{ encoding_check }
|
| Boom
| Bam
| # ./spec/rspec/core/formatters/exception_presenter_spec.rb:#{line_num}
EOS
end

it "allows the caller to specify additional indentation" do
the_presenter = Formatters::ExceptionPresenter.new(exception, example, :indentation => 4)

Expand Down
20 changes: 20 additions & 0 deletions spec/rspec/core/metadata_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,26 @@ def group_value_for(*args)
end
end

it "omits description from groups with a `nil` description" do
value = nil

RSpec.describe do
value = example("example").metadata[:full_description]
end

expect(value).to eq("example")
end

it "omits description from groups with a description of `''`" do
value = nil

RSpec.describe "" do
value = example("example").metadata[:full_description]
end

expect(value).to eq("example")
end

it "concats nested example group descriptions" do
group_value = example_value = nil

Expand Down
33 changes: 33 additions & 0 deletions spec/rspec/core/reporter_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module RSpec::Core
include FormatterSupport

let(:config) { Configuration.new }
let(:world) { World.new(config) }
let(:reporter) { Reporter.new config }
let(:start_time) { Time.now }
let(:example) { super() }
Expand Down Expand Up @@ -280,5 +281,37 @@ module RSpec::Core
reporter.finish
end
end

describe "#notify_non_example_exception" do
it "sends a `message` notification that contains the formatted exception details" do
if RSpec::Support::Ruby.jruby_9000?
pending "RSpec gets `Unable to find matching line from backtrace` on JRuby 9000"
end

formatter_out = StringIO.new
formatter = Formatters::ProgressFormatter.new(formatter_out)
reporter.register_listener formatter, :message

line = __LINE__ + 1
exception = 1 / 0 rescue $!
reporter.notify_non_example_exception(exception, "NonExample Context")

expect(formatter_out.string).to start_with(<<-EOS.gsub(/^ +\|/, '').chomp)
|
|NonExample Context
|Failure/Error: exception = 1 / 0 rescue $!
|
|ZeroDivisionError:
| divided by 0
|# #{Metadata.relative_path(__FILE__)}:#{line}
EOS
end

it "records the fact that a non example failure has occurred" do
expect {
reporter.notify_non_example_exception(Exception.new, "NonExample Context")
}.to change(world, :non_example_failure).from(a_falsey_value).to(true)
end
end
end
end
Loading