rubyunit-testingrackrack-test

How do I get the Sinatra app instance that's being tested by rack-test?


I want to get hold of the app instance being tested by rack-test so that I can mock some of its methods. I thought I could simply save the app instance in the app method, but for some strange reason that doesn't work. It seems like rack-test simply uses the instance to get the class, then creates its own instance.

I've made a test to demonstrate my issue (it requires the gems "sinatra", "rack-test" and "rr" to run):

require "sinatra"
require "minitest/spec"
require "minitest/autorun"
require "rack/test"
require "rr"

describe "instantiated app" do
  include Rack::Test::Methods

  def app
    cls = Class.new(Sinatra::Base) do
      get "/foo" do
        $instance_id = self.object_id

        generate_response
      end

      def generate_response
        [200, {"Content-Type" => "text/plain"}, "I am a response"]
      end
    end

    # Instantiate the actual class, and not a wrapped class made by Sinatra
    @app = cls.new!

    return @app
  end

  it "should have the same object id inside response handlers" do
    get "/foo"

    assert_equal $instance_id, @app.object_id,
      "Expected object IDs to be the same"
  end

  it "should trigger mocked instance methods" do
    mock(@app).generate_response {
      [200, {"Content-Type" => "text/plain"}, "I am MOCKED"]
    }

    get "/foo"

    assert_equal "I am MOCKED", last_response.body
  end
end

How come rack-test isn't using the instance I provided? How do I get hold of the instance that rack-test is using, so that I can mock the generate_response method?


Update

I have made no progress. It turns out rack-test creates the tested instance on the fly when the first request is made (i.e. get("/foo")), so it's not possible to mock the app instance before then.

I have used rr's stub.proxy(...) to intercept .new, .new! and .allocate; and added a puts statement with the instance's class name and object_id. I have also added such statements in the tested class' constructor, as well as a request handler.

Here's the output:

From constructor: <TestSubject 47378836917780>
Proxy intercepted new! instance: <TestSubject 47378836917780>
Proxy intercepted new instance: <Sinatra::Wrapper 47378838065200>
From request handler: <TestSubject 47378838063980>

Notice the object ids. The tested instance (printed from the request handler) never went through .new and was never initialized.

So, confusingly, the instance being tested is never created, but somehow exists none the less. My guess was that allocate was being used, but the proxy intercept shows that it doesn't. I ran TestSubject.allocate myself to verify that the intercept works, and it does.

I also added the inherited, included, extended and prepended hooks to the tested class and added print statements, but they were never called. This leaves me completely and utterly stumped as to what kind of horrible black magic rack-test is up to under the hood.

So to summarize: the tested instance is created on the fly when the first request is sent. The tested instance is created by fel magic and dodges all attempts to catch it with a hook, so I can find no way to mock it. It almost feels like the author of rake-test has gone to extraordinary lengths to make sure the app instance can't be touched during testing.

I am still fumbling around for a solution.


Solution

  • Ok, I finally got it.

    The problem, all along, turned out to be Sinatra::Base.call. Internally, it does dup.call!(env). In other words, every time you run call, Sinatra will duplicate your app instance and send the request to the duplicate, circumventing all mocks and stubs. That explains why none of the life cycle hooks were triggered, since presumably dup uses some low level C magic to clone the instance (citation needed.)

    rack-test doesn't do anything convoluted at all, all it does it call app() to retrieve the app, and then call .call(env) on the app. All I need to do then, is stub out the .call method on my class and make sure Sinatra's magic isn't being inserted anywhere. I can use .new! on my app to stop Sinatra from inserting a wrapper and a stack, and I can use .call! to call my app without Sinatra duplicating my app instance.

    Note: I can no longer just create an anonymous class inside the app function, since that would create a new class each time app() is called and leave me unable to mock it.

    Here's the test from the question, updated to work:

    require "sinatra"
    require "minitest/spec"
    require "minitest/autorun"
    require "rack/test"
    require "rr"
    
    describe "sinatra app" do
      include Rack::Test::Methods
    
      class TestSubject < Sinatra::Base
        get "/foo" do
          generate_response
        end
    
        def generate_response
          [200, {"Content-Type" => "text/plain"}, "I am a response"]
        end
      end
    
      def app
        return TestSubject
      end
    
      it "should trigger mocked instance methods" do
        stub(TestSubject).call { |env|
          instance = TestSubject.new!
    
          mock(instance).generate_response {
            [200, {"Content-Type" => "text/plain"}, "I am MOCKED"]
          }
    
          instance.call! env
        }
    
        get "/foo"
    
        assert_equal "I am MOCKED", last_response.body
      end
    end