Back to Repositories

Testing ErrorPage Component Integration in BetterErrors

This test suite examines the ErrorPage functionality in BetterErrors, focusing on error display, variable inspection, and REPL integration. It verifies error message rendering, variable binding handling, and interactive debugging features across different runtime environments.

Test Coverage Overview

The test suite provides comprehensive coverage of ErrorPage functionality including:

  • Error message display and formatting
  • Request path and exception class handling
  • ActionableError integration with ActiveSupport
  • Variable inspection with size limits and filtering
  • REPL evaluation capabilities

Implementation Analysis

The testing approach uses RSpec’s behavior-driven development patterns with extensive context-specific testing. It implements HTML matchers for precise DOM verification and employs careful exception handling to test error scenarios. The suite makes heavy use of let blocks for setup and mock objects for isolation.

Technical Details

Key technical components include:

  • RSpec for test framework
  • HTML matchers for response validation
  • binding_of_caller integration testing
  • Mock objects and stubbing
  • Environment variable handling
  • ObjectSpace feature detection

Best Practices Demonstrated

The test suite exemplifies several testing best practices:

  • Isolated test contexts using let blocks
  • Platform-specific test skipping
  • Comprehensive edge case coverage
  • Clean setup/teardown patterns
  • Detailed error scenario validation

bettererrors/better_errors

spec/better_errors/error_page_spec.rb

            
require "spec_helper"

class ErrorPageTestIgnoredClass; end

module BetterErrors
  describe ErrorPage do
    # It's necessary to use HTML matchers here that are specific as possible.
    # This is because if there's an exception within this file, the lines of code will be reflected in the
    # generated HTML, so any strings being matched against the HTML content will be there if they're within 5
    # lines of code of the exception that was raised.
    
    let!(:exception) { raise ZeroDivisionError, "you divided by zero you silly goose!" rescue $! }

    let(:error_page) { ErrorPage.new exception, { "PATH_INFO" => "/some/path" } }

    let(:response) { error_page.render_main("CSRF_TOKEN", "CSP_NONCE") }

    let(:exception_binding) {
      local_a = :value_for_local_a
      local_b = :value_for_local_b

      @inst_c = :value_for_inst_c
      @inst_d = :value_for_inst_d

      binding
    }

    it "includes the error message" do
      expect(response).to have_tag('.exception p', text: /you divided by zero you silly goose!/)
    end

    it "includes the request path" do
      expect(response).to have_tag('.exception h2', %r{/some/path})
    end

    it "includes the exception class" do
      expect(response).to have_tag('.exception h2', /ZeroDivisionError/)
    end

    context 'when ActiveSupport::ActionableError is available' do
      before do
        skip "ActiveSupport missing on this platform" unless Object.constants.include?(:ActiveSupport)
        skip "ActionableError missing on this platform" unless ActiveSupport.constants.include?(:ActionableError)
      end

      context 'when ActiveSupport provides one or more actions for this error type' do
        let(:exception_class) {
          Class.new(StandardError) do
            include ActiveSupport::ActionableError

            action "Do a thing" do
              puts "Did a thing"
            end
          end
        }
        let(:exception) { exception_binding.eval("raise exception_class") rescue $! }

        it "includes a fix-action form for each action" do
          expect(response).to have_tag('.fix-actions') do
            with_tag('form.button_to')
            with_tag('form.button_to input[type=submit][value="Do a thing"]')
          end
        end
      end

      context 'when ActiveSupport does not provide any actions for this error type' do
        let(:exception_class) {
          Class.new(StandardError)
        }
        let(:exception) { exception_binding.eval("raise exception_class") rescue $! }

        it "does not include a fix-action form" do
          expect(response).not_to have_tag('.fix-actions')
        end
      end
    end

    context "variable inspection" do
      let(:html) { error_page.do_variables("index" => 0)[:html] }
      let(:exception) { exception_binding.eval("raise") rescue $! }

      it 'includes an editor link for the full path of the current frame' do
        expect(html).to have_tag('.location .filename') do
          with_tag('a[href*="better_errors"]')
        end
      end

      context 'when BETTER_ERRORS_INSIDE_FRAME is set in the environment' do
        before do
          ENV['BETTER_ERRORS_INSIDE_FRAME'] = '1'
        end
        after do
          ENV['BETTER_ERRORS_INSIDE_FRAME'] = nil
        end

        it 'includes an editor link with target=_blank' do
          expect(html).to have_tag('.location .filename') do
            with_tag('a[href*="better_errors"][target="_blank"]')
          end
        end
      end

      context 'when BETTER_ERRORS_INSIDE_FRAME is not set in the environment' do
        it 'includes an editor link without target=_blank' do
          expect(html).to have_tag('.location .filename') do
            with_tag('a[href*="better_errors"]:not([target="_blank"])')
          end
        end
      end

      context "when binding_of_caller is loaded" do
        before do
          skip "binding_of_caller is not loaded" unless BetterErrors.binding_of_caller_available?
        end

        it "shows local variables" do
          expect(html).to have_tag('div.variables tr') do
            with_tag('td.name', text: 'local_a')
            with_tag('pre', text: ':value_for_local_a')
          end
          expect(html).to have_tag('div.variables tr') do
            with_tag('td.name', text: 'local_b')
            with_tag('pre', text: ':value_for_local_b')
          end
        end

        it "shows instance variables" do
          expect(html).to have_tag('div.variables tr') do
            with_tag('td.name', text: '@inst_c')
            with_tag('pre', text: ':value_for_inst_c')
          end
          expect(html).to have_tag('div.variables tr') do
            with_tag('td.name', text: '@inst_d')
            with_tag('pre', text: ':value_for_inst_d')
          end
        end

        context 'when ignored_classes includes the class name of a local variable' do
          before do
            allow(BetterErrors).to receive(:ignored_classes).and_return(['ErrorPageTestIgnoredClass'])
          end

          let(:exception_binding) {
            local_a = :value_for_local_a
            local_b = ErrorPageTestIgnoredClass.new

            @inst_c = :value_for_inst_c
            @inst_d = ErrorPageTestIgnoredClass.new

            binding
          }

          it "does not include that value" do
            expect(html).to have_tag('div.variables tr') do
              with_tag('td.name', text: 'local_a')
              with_tag('pre', text: ':value_for_local_a')
            end
            expect(html).to have_tag('div.variables tr') do
              with_tag('td.name', text: 'local_b')
              with_tag('.unsupported', text: /Instance of ignored class/)
              with_tag('.unsupported', text: /BetterErrors\.ignored_classes/)
            end
            expect(html).to have_tag('div.variables tr') do
              with_tag('td.name', text: '@inst_c')
              with_tag('pre', text: ':value_for_inst_c')
            end
            expect(html).to have_tag('div.variables tr') do
              with_tag('td.name', text: '@inst_d')
              with_tag('.unsupported', text: /Instance of ignored class/)
              with_tag('.unsupported', text: /BetterErrors\.ignored_classes/)
            end
          end
        end

        it "does not show filtered variables" do
          allow(BetterErrors).to receive(:ignored_instance_variables).and_return([:@inst_d])
          expect(html).to have_tag('div.variables tr') do
            with_tag('td.name', text: '@inst_c')
            with_tag('pre', text: ':value_for_inst_c')
          end
          expect(html).not_to have_tag('div.variables td.name', text: '@inst_d')
        end

        context 'when maximum_variable_inspect_size is set' do
          before do
            BetterErrors.maximum_variable_inspect_size = 1010
          end

          context 'on a platform with ObjectSpace' do
            before do
              skip "Missing on this platform" unless Object.constants.include?(:ObjectSpace)
            end

            context 'with a variable that is smaller than maximum_variable_inspect_size' do
              let(:exception_binding) {
                @small = content

                binding
              }
              let(:content) { 'A' * 480 }

              it "shows the variable content" do
                expect(html).to have_tag('div.variables', text: %r{#{content}})
              end
            end

            context 'with a variable that is larger than maximum_variable_inspect_size' do
              context 'but has an #inspect that returns a smaller value' do
                let(:exception_binding) {
                  @big = content

                  binding
                }
                let(:content) {
                  class ExtremelyLargeInspectableTestValue
                    def initialize
                      @a = 'A' * 1101
                    end
                    def inspect
                      "shortval"
                    end
                  end
                  ExtremelyLargeInspectableTestValue.new
                }

                it "shows the variable content" do
                  expect(html).to have_tag('div.variables', text: /shortval/)
                end
              end
              context 'and does not implement #inspect' do
                let(:exception_binding) {
                  @big = content

                  binding
                }
                let(:content) { 'A' * 1101 }

                it "includes an indication that the variable was too large" do
                  expect(html).not_to have_tag('div.variables', text: %r{#{content}})
                  expect(html).to have_tag('div.variables', text: /Object too large/)
                end
              end

              context "when the variable's class is anonymous" do
                let(:exception_binding) {
                  @big_anonymous = Class.new do
                    def initialize
                      @content = 'A' * 1101
                    end
                  end.new

                  binding
                }

                it "does not attempt to show the class name" do
                  expect(html).to have_tag('div.variables tr') do
                    with_tag('td.name', text: '@big_anonymous')
                    with_tag('.unsupported', text: /Object too large/)
                    with_tag('.unsupported', text: /Adjust BetterErrors.maximum_variable_inspect_size/)
                  end
                end
              end
            end
          end

          context 'on a platform without ObjectSpace' do
            before do
              Object.send(:remove_const, :ObjectSpace) if Object.constants.include?(:ObjectSpace)
            end
            after do
              require "objspace" rescue nil
            end

            context 'with a variable that is smaller than maximum_variable_inspect_size' do
              let(:exception_binding) {
                @small = content

                binding
              }
              let(:content) { 'A' * 480 }

              it "shows the variable content" do
                expect(html).to have_tag('div.variables', text: %r{#{content}})
              end
            end

            context 'with a variable that is larger than maximum_variable_inspect_size' do
              context 'but has an #inspect that returns a smaller value' do
                let(:exception_binding) {
                  @big = content

                  binding
                }
                let(:content) {
                  class ExtremelyLargeInspectableTestValue
                    def initialize
                      @a = 'A' * 1101
                    end
                    def inspect
                      "shortval"
                    end
                  end
                  ExtremelyLargeInspectableTestValue.new
                }

                it "shows the variable content" do
                  expect(html).to have_tag('div.variables', text: /shortval/)
                end
              end
              context 'and does not implement #inspect' do
                let(:exception_binding) {
                  @big = content

                  binding
                }
                let(:content) { 'A' * 1101 }

                it "includes an indication that the variable was too large" do
                  
                  expect(html).not_to have_tag('div.variables', text: %r{#{content}})
                  expect(html).to have_tag('div.variables', text: /Object too large/)
                end
              end
            end

            context "when the variable's class is anonymous" do
              let(:exception_binding) {
                @big_anonymous = Class.new do
                  def initialize
                    @content = 'A' * 1101
                  end
                end.new

                binding
              }

              it "does not attempt to show the class name" do
                expect(html).to have_tag('div.variables tr') do
                  with_tag('td.name', text: '@big_anonymous')
                  with_tag('.unsupported', text: /Object too large/)
                  with_tag('.unsupported', text: /Adjust BetterErrors.maximum_variable_inspect_size/)
                end
              end
            end
          end
        end

        context 'when maximum_variable_inspect_size is disabled' do
          before do
            BetterErrors.maximum_variable_inspect_size = nil
          end

          let(:exception_binding) {
            @big = content

            binding
          }
          let(:content) { 'A' * 100_001 }

          it "includes the content of large variables" do
            expect(html).to have_tag('div.variables', text: %r{#{content}})
            expect(html).not_to have_tag('div.variables', text: /Object too large/)
          end
        end
      end

      context "when binding_of_caller is not loaded" do
        before do
          skip "binding_of_caller is loaded" if BetterErrors.binding_of_caller_available?
        end

        it "tells the user to add binding_of_caller to their gemfile to get fancy features" do
          expect(html).not_to have_tag('div.variables', text: /gem "binding_of_caller"/)
        end
      end
    end

    it "doesn't die if the source file is not a real filename" do
      allow(exception).to receive(:__better_errors_bindings_stack).and_return([])
      allow(exception).to receive(:backtrace).and_return([
        "<internal:prelude>:10:in `spawn_rack_application'"
      ])
      expect(response).to have_tag('.frames li .location .filename', text: '<internal:prelude>')
    end

    context 'with an exception with blank lines' do
      class SpacedError < StandardError
        def initialize(message = nil)
          message = "\n\n#{message}" if message
          super
        end
      end

      let!(:exception) { raise SpacedError, "Danger Warning!" rescue $! }

      it 'does not include leading blank lines in exception_message' do
        expect(exception.message).to match(/\A\n\n/)
        expect(error_page.exception_message).not_to match(/\A\n\n/)
      end
    end

    describe '#do_eval' do
      let(:exception) { exception_binding.eval("raise") rescue $! }
      subject(:do_eval) { error_page.do_eval("index" => 0, "source" => command) }
      let(:command) { 'EvalTester.stuff_was_done(:yep)' }
      before do
        stub_const('EvalTester', eval_tester)
      end
      let(:eval_tester) { double('EvalTester', stuff_was_done: 'response') }

      context 'without binding_of_caller' do
        before do
          skip("Disabled with binding_of_caller") if defined? ::BindingOfCaller
        end

        it "does not evaluate the code" do
          do_eval
          expect(eval_tester).to_not have_received(:stuff_was_done).with(:yep)
        end

        it 'returns an error indicating no REPL' do
          expect(do_eval).to include(
            error: "REPL unavailable in this stack frame",
          )
        end
      end

      context 'with binding_of_caller available' do
        before do
          skip("Disabled without binding_of_caller") unless defined? ::BindingOfCaller
        end

        context 'with Pry disabled or unavailable' do
          it "evaluates the code" do
            do_eval
            expect(eval_tester).to have_received(:stuff_was_done).with(:yep)
          end

          it 'returns a hash of the code and its result' do
            expect(do_eval).to include(
              highlighted_input: /stuff_was_done/,
              prefilled_input: '',
              prompt: '>>',
              result: "=> \"response\"\n",
            )
          end
        end

        context 'with Pry enabled' do
          before do
            skip("Disabled without pry") unless defined? ::Pry

            BetterErrors.use_pry!
            # Cause the provider to be unselected, so that it will be re-detected.
            BetterErrors::REPL.provider = nil
          end
          after do
            BetterErrors::REPL::PROVIDERS.shift
            BetterErrors::REPL.provider = nil

            # Ensure the Pry REPL file has not been included. If this is not done,
            # the constant leaks into other examples.
            BetterErrors::REPL.send(:remove_const, :Pry)
          end

          it "evaluates the code" do
            BetterErrors::REPL.provider
            do_eval
            expect(eval_tester).to have_received(:stuff_was_done).with(:yep)
          end

          it 'returns a hash of the code and its result' do
            expect(do_eval).to include(
              highlighted_input: /stuff_was_done/,
              prefilled_input: '',
              prompt: '>>',
              result: "=> \"response\"\n",
            )
          end
        end
      end
    end
  end
end