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?
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.
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With