Wednesday, October 7, 2009

Describing Rack with BDD

‹prev | My Chain | next›

For the past couple of nights, I have been spiking a bit of Rack middleware that thumbnails images. A conceptual overview of the flow:
              | Inbound                  ^  Response              
| Request | (possibly altered
| | by the ThumbNailer)
| |
+----v--------------------------+-----+
| |
| ThumbNailer |
| Rack Middleware |
| |
| |
| |
| |
+----+---------------------------^----+
| |
| |
+----v---------------------------+----+
| |
| Target App |
| (more Rack Middleware or |
| a Rack application) |
| |
| |
| |
+-------------------------------------+
Having deleted the code, tonight I do it right, driving the design by example.

When testing middleware, Rack::Test appreciates an app method that describes the middleware that wraps another Rack application. The "other" application is the kind of thing that mocks were made for. The beautiful thing about Rack is that any Rack component only need respond to a :call method (taking an env argument), which returns an array of HTTP status code, HTTP headers, and the body (something that responds to :each).

Thus, a target Rack application can be mocked, in RSpec, as:
def app
@target_app = mock("Target Rack Application")
@target_app.
stub!(:call).
and_return([200, { }, "Target app"])

Rack::ThumbNailer.new(@target_app)
end
With that, I can start describing how the middleware should behave. First up, the case in which the request is accessing a non-image resource:
context "Accessing a non-image resource" do
it "should return the target app" do
get "/foo"
last_response.body.should contain("Target app")
end
end
I have yet to create the ThumbNailer class, so running this example yields this failure:
jaynestown% spec ./thumbnailer_spec.rb 
/usr/local/lib/site_ruby/1.8/rubygems/custom_require.rb:31:in `gem_original_require': no such file to load -- thumbnailer (LoadError)
from /usr/local/lib/site_ruby/1.8/rubygems/custom_require.rb:31:in `require'
from ./thumbnailer_spec.rb:3
from /home/cstrom/.gem/ruby/1.8/gems/rspec-1.1.12/lib/spec/runner/example_group_runner.rb:15:in `load'
from /home/cstrom/.gem/ruby/1.8/gems/rspec-1.1.12/lib/spec/runner/example_group_runner.rb:15:in `load_files'
from /home/cstrom/.gem/ruby/1.8/gems/rspec-1.1.12/lib/spec/runner/example_group_runner.rb:14:in `each'
from /home/cstrom/.gem/ruby/1.8/gems/rspec-1.1.12/lib/spec/runner/example_group_runner.rb:14:in `load_files'
from /home/cstrom/.gem/ruby/1.8/gems/rspec-1.1.12/lib/spec/runner/options.rb:94:in `run_examples'
from /home/cstrom/.gem/ruby/1.8/gems/rspec-1.1.12/lib/spec/runner/command_line.rb:9:in `run'
from /home/cstrom/.gem/ruby/1.8/gems/rspec-1.1.12/bin/spec:4
from /home/cstrom/.gem/ruby/1.8/bin/spec:19:in `load'
from /home/cstrom/.gem/ruby/1.8/bin/spec:19
Starting the change-the-message or make-it-pass cycle, I change the message by defining the class:
module Rack
class ThumbNailer
end
end
The example now produces this output when run:
jaynestown% spec ./thumbnailer_spec.rb
F

1)
ArgumentError in 'Accessing a non-image resource should return the target app'
wrong number of arguments (1 for 0)
./thumbnailer_spec.rb:11:in `initialize'
./thumbnailer_spec.rb:11:in `new'
./thumbnailer_spec.rb:11:in `app'
/home/cstrom/.gem/ruby/1.8/gems/rack-test-0.5.0/lib/rack/test/methods.rb:31:in `build_rack_mock_session'
/home/cstrom/.gem/ruby/1.8/gems/rack-test-0.5.0/lib/rack/test/methods.rb:27:in `rack_mock_session'
/home/cstrom/.gem/ruby/1.8/gems/rack-test-0.5.0/lib/rack/test/methods.rb:42:in `build_rack_test_session'
/home/cstrom/.gem/ruby/1.8/gems/rack-test-0.5.0/lib/rack/test/methods.rb:38:in `rack_test_session'
/home/cstrom/.gem/ruby/1.8/gems/rack-test-0.5.0/lib/rack/test/methods.rb:46:in `current_session'
./thumbnailer_spec.rb:18:

Finished in 0.007368 seconds

1 example, 1 failure
This failure is telling me that my middleware needs to have an initializer that accepts one argument—the target Rack application (the mock in app or my Sinatra app in real life):
module Rack
class ThumbNailer
def initialize(app)
@app = app
end
end
end
The message has now changed to:
jaynestown% spec ./thumbnailer_spec.rb
F

1)
NoMethodError in 'Accessing a non-image resource should return the target app'
undefined method `call' for #
/home/cstrom/.gem/ruby/1.8/gems/rack-test-0.5.0/lib/rack/mock_session.rb:30:in `request'
/home/cstrom/.gem/ruby/1.8/gems/rack-test-0.5.0/lib/rack/test.rb:207:in `process_request'
/home/cstrom/.gem/ruby/1.8/gems/rack-test-0.5.0/lib/rack/test.rb:57:in `get'
./thumbnailer_spec.rb:18:

Finished in 0.082298 seconds

1 example, 1 failure
Now my example is telling me that my Rack middleware needs to support a call method:
    def call
end
The message now tells me that any Rack application needs to have a call method that accepts an argument (the current environment):
jaynestown% spec ./thumbnailer_spec.rb
F

1)
ArgumentError in 'Accessing a non-image resource should return the target app'
wrong number of arguments (1 for 0)
/home/cstrom/.gem/ruby/1.8/gems/rack-test-0.5.0/lib/rack/mock_session.rb:30:in `call'
/home/cstrom/.gem/ruby/1.8/gems/rack-test-0.5.0/lib/rack/mock_session.rb:30:in `request'
/home/cstrom/.gem/ruby/1.8/gems/rack-test-0.5.0/lib/rack/test.rb:207:in `process_request'
/home/cstrom/.gem/ruby/1.8/gems/rack-test-0.5.0/lib/rack/test.rb:57:in `get'
./thumbnailer_spec.rb:18:

Finished in 0.010089 seconds

1 example, 1 failure
I update the call method to accept a single argument:
    def call(env)
end
This changes message—this time telling me that the call method in a Rack application has to return something that responds to each:
jaynestown% spec ./thumbnailer_spec.rb
F

1)
NoMethodError in 'Accessing a non-image resource should return the target app'
undefined method `each' for nil:NilClass
/home/cstrom/.gem/ruby/1.8/gems/rack-1.0.0/lib/rack/mock.rb:120:in `initialize'
/home/cstrom/.gem/ruby/1.8/gems/rack-test-0.5.0/lib/rack/mock_session.rb:31:in `new'
/home/cstrom/.gem/ruby/1.8/gems/rack-test-0.5.0/lib/rack/mock_session.rb:31:in `request'
/home/cstrom/.gem/ruby/1.8/gems/rack-test-0.5.0/lib/rack/test.rb:207:in `process_request'
/home/cstrom/.gem/ruby/1.8/gems/rack-test-0.5.0/lib/rack/test.rb:57:in `get'
./thumbnailer_spec.rb:18:

Finished in 0.009865 seconds

1 example, 1 failure
To change the message in this case, I need to reply with an array, including the HTTP status, a hash of the HTTP headers, and the content of the response:
    def call(env)
[200, { }, "Foo"]
end
Here, I finally reach a point that my middleware supports the full Rack specification. This failure is because I am returning a hard-coded string ("Foo") rather than the output of the target application:
jaynestown% spec ./thumbnailer_spec.rb
F

1)
'Accessing a non-image resource should return the target app' FAILED
expected the following element's content to include "Target app":
Foo
./thumbnailer_spec.rb:21:

Finished in 0.011375 seconds

1 example, 1 failure
To change this message, I only need change the return value. Recalling that the @app being initialized is the mock Rack object, and that, in this case, I want the middleware to pass-thru the request directly to the target (mock) Rack application, I can make the example pass with:
module Rack
class ThumbNailer
def initialize(app)
@app = app
end
def call(env)
@app.call(env)
end
end
end
At this point, the complete example reads:
require 'spec'
require 'rack/test'
require 'thumbnailer'
require 'webrat'

def app
@target_app = mock("Target Rack Application")
@target_app.
stub!(:call).
and_return([200, { }, "Target app"])

Rack::ThumbNailer.new(@target_app)
end

context "Accessing a non-image resource" do
include Rack::Test::Methods
include Webrat::Matchers

it "should return the target app" do
get "/foo"
last_response.body.should contain("Target app")
end
end
When I execute it, I get:
jaynestown% spec ./thumbnailer_spec.rb -cfs

Accessing a non-image resource
- should return the target app

Finished in 0.027749 seconds

1 example, 0 failures
At this point, I have BDD'd my way through the entire Rack specification and have a valid Rack middleware component. Tomorrow, I will continue to drive the middleware to something more useful than a simple pass-thru.

No comments:

Post a Comment