I used to have a guard around my rescue_from
config, too, like:
unless Rails.application.config.consider_all_requests_local
rescue_from Exception, with: :render_error
…
end
... which worked fine, until I was trying to figure out how to make it handle errors and show pretty custom error pages (like it does in production) in some tests. @Aaron K's answer was helpful in explaining why the check can't be evaluated within the class definition, and has to be checked within the actual error handler (at run time) instead. But that only solved part of the problem for me.
Here's what I did...
In ApplicationController
, remember to re-raise any errors if the show_detailed_exceptions
flag (a more appropriate check than consider_all_requests_local
) is true. In other words, only do the production error handling if the app/request is configured to handle errors for production; otherwise "pass" and re-raise the error.
rescue_from Exception, with: :render_error
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
rescue_from ActionController::RoutingError, with: :render_not_found
rescue_from AbstractController::ActionNotFound, with: :render_not_found
def show_detailed_exceptions?
# Rails.application.config.consider_all_requests_local causes this to be set to true as well.
request.get_header("action_dispatch.show_detailed_exceptions")
end
def render_not_found(exception = nil, template = 'errors/not_found')
raise exception if show_detailed_exceptions?
logger.error exception if exception
render template, formats: [:html], status: :not_found
end
def render_error(exception)
raise exception if show_detailed_exceptions?
deliver_exception_notification(exception)
logger.error exception
# Prevent AbstractController::DoubleRenderError in case we've already rendered something
method(:response_body=).super_method.call(nil)
respond_to do |format|
format.html { render 'errors/internal_server_error', formats: [:html], status: :internal_server_error }
format.any { raise exception }
end
end
Add to spec/support/handle_exceptions_like_production.rb
:
shared_context 'handle_exceptions_like_production', handle_exceptions_like_production: true do
before do |example|
case example.metadata[:type]
when :feature
method = Rails.application.method(:env_config)
allow(Rails.application).to receive(:env_config).with(no_args) do
method.call.merge(
'action_dispatch.show_exceptions' => true,
'action_dispatch.show_detailed_exceptions' => false,
'consider_all_requests_local' => true
)
end
when :controller
# In controller tests, we can only test *controller* behavior, not middleware behavior. We
# can disable show_detailed_exceptions here but we can *only* test any behaviors that depend
# on it that are defined in our *controller* (ApplicationController). Because the request
# doesn't go through the middleware (DebugExceptions, ShowExceptions) — which is what actually
# renders the production error pages — in controller tests, we may not see the exact same
# behavior as we would in production. Feature (end-to-end) tests may be needed to more
# accurately simulate a full production stack with middlewares.
request.set_header 'action_dispatch.show_detailed_exceptions', false
else
raise "expected example.metadata[:type] to be one of :feature or :controller but was #{example.metadata[:type]}"
end
end
end
RSpec.configure do |config|
config.include_context 'handle_exceptions_like_production', :handle_exceptions_like_production
end
Then, in end-to-end (feature) tests where you want it to handle exceptions like it does in production (in other words, to not treat it like a local request), just add :handle_exceptions_like_production
to your example group:
describe 'something', :handle_exceptions_like_production do
it …
end
For example:
spec/features/exception_handling_spec.rb
:
describe 'exception handling', js: false do
context 'default behavior' do
it do |example|
expect(example.metadata[:handle_exceptions_like_production]).to eq nil
end
describe 'ActiveRecord::RecordNotFound' do
it do
expect {
visit '/users/0'
}.to raise_exception(ActiveRecord::RecordNotFound)
end
end
describe 'ActionController::RoutingError' do
it do
expect {
visit '/advertisers/that_track_you_and_show_you_personalized_ads/'
}.to raise_exception(ActionController::RoutingError)
end
end
describe 'RuntimeError => raised' do
it do
expect {
visit '/test/exception'
}.to raise_exception(RuntimeError, 'A test exception')
end
end
end
context 'when :handle_exceptions_like_production is true', :handle_exceptions_like_production do
describe 'ActiveRecord::RecordNotFound => production not_found page' do
it do
expect {
visit '/users/0'
}.to_not raise_exception
expect_not_found
end
end
describe 'ActionController::RoutingError => production not_found page' do
it do
visit '/advertisers/that_track_you_and_show_you_personalized_ads/'
expect_not_found
end
end
describe 'RuntimeError => production not_found page' do
it do
visit '/test/exception'
expect_application_error
end
end
end
end
It can also be used in controller tests — if you have production error-handling defined in your ApplicationController
. spec/controllers/exception_handling_spec.rb
:
describe 'exception handling' do
context 'default behavior' do
describe UsersController do
it do
expect {
get 'show', params: {id: 0}
}.to raise_exception(ActiveRecord::RecordNotFound)
end
end
describe TestController do
it do
expect {
get 'exception'
}.to raise_exception(RuntimeError, 'A test exception')
end
end
end
context 'when handle_exceptions_like_production: true', :handle_exceptions_like_production do
describe UsersController do
it do
expect {
get 'show', params: {id: 0}
}.to_not raise_exception
expect(response).to render_template('errors/not_found')
end
end
describe TestController do
it do
expect {
get 'exception'
}.to_not raise_exception
expect(response).to render_template('errors/internal_server_error')
end
end
end
end
Tested with: rspec 3.9
, rails 5.2