rspec-cramp 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2011 Martin Bilski
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,90 @@
1
+ A set of rspec matchers and helpers that make it easier to write specs for [cramp](http://cramp.in).
2
+
3
+ Quick start
4
+ -----------
5
+
6
+ require 'rspec/cramp'
7
+
8
+ describe HelloWorld, :cramp => true do
9
+ def app
10
+ HelloWorld
11
+ end
12
+
13
+ it "should respond to a GET request" do
14
+ # Various ways to match a response code.
15
+ get("/").should respond_with :status => :ok # Matches responses from 200 to 299.
16
+ get("/").should respond_with :status => 200 # Matches only 200.
17
+ get("/").should respond_with :status => "200" # Same as above.
18
+ get("/").should respond_with :status => /^2.*/ # Matches response codes starting with 2.
19
+ get("/").should_not respond_with :status => :error # Matches any HTTP error.
20
+ end
21
+
22
+ it "should respond with text starting with 'Hello'" do
23
+ get("/").should respond_with :body => /^Hello.*/
24
+ end
25
+
26
+ it "should respond with 'Hello, world!'" do
27
+ get("/").should respond_with :body => "Hello, world!"
28
+ end
29
+
30
+ it "should respond with html" do
31
+ get("/").should respond_with :headers => {"Content-Type" => "text/html"}
32
+ get("/").should_not respond_with :headers => {"Content-Type" => "text/plain"}
33
+ get("/").should_not respond_with :headers => {"Unexpected-Header" => /.*/}
34
+ end
35
+ end
36
+
37
+ The matcher is fairly flexible, supports regular expressions and also works with multipart responses (more than one `Cramp::Action.render`), SSE and so on. I'll create more examples and docs but for the time being, pls. take a look at the code and [examples](https://github.com/bilus/rspec-cramp/tree/master/spec/examples).
38
+
39
+ **DISCLAIMER:** I haven't done any work with WebSockets yet so if there is anyone willing to add support for WebSockets, please [tweet me](http://twitter.com/#!/MartinBilski)
40
+
41
+ Project status
42
+ --------------
43
+
44
+ **IMPORTANT:** This is work in progress.
45
+
46
+ 1. I have created a gem and restructured the files 'a bit'. I'll publish the gem soon in its current form. Right now, you can build it using the provided gemspec.
47
+ 2. There are still some things I'll take care of soon (esp. better failure messages).
48
+ 3. I extracted the code from one of my projects and rewrote the matchers from scratch test-first. Still, after the weekend I plan to actually use it to replace the 'legacy' matchers in my project; this will probably uncover some bugs and may make me add more functionality. *UPDATE: I'm working on it right now.*
49
+
50
+ If you have any comments regarding the code as it is now (I know it's a bit messy), please feel free to tweet [@MartinBilski](http://twitter.com/#!/MartinBilski)
51
+
52
+ Notes
53
+ ----
54
+
55
+ 1. The previous version had a problem handling exceptions raised in `on_start` or `on_finish` or with `on_start` that called `finish` without rendering anything because the request helper methods always try to read one body chunk after a successful response (200-299).
56
+
57
+ Actually, it wasn't a very big deal, the call would simply time out and raise `Timeout::Error` with a special error message hinting at the problem: *execution expired (No render call in action?)*.
58
+
59
+ The current version, comes with a [monkey-patched `Cramp::Action`](https://github.com/bilus/rspec-cramp/tree/master/lib/rspec_cramp.rb) which now renders the exception info if it is raised in `on_start` or in `on_finish`.
60
+
61
+ See [this example spec](https://github.com/bilus/rspec-cramp/tree/master/spec/examples/errors_spec.rb) to see error handling in action.
62
+
63
+ I'm definitely open to suggestions, especially how this can be fixed without the cramp surgery. Is the original timeout-based solution better? Unfortunately, the matcher by default always loads one chunk of response body for a successful response.
64
+
65
+ 2. This work is based on [Pratik Naik's code](https://github.com/lifo/cramp/blob/master/lib/cramp/test_case.rb) and writing specs in a similar fashion is still supported though I added a helper for loading the body and a response matcher and some accessors to make your life easier.
66
+
67
+ describe MyCrampAction, :cramp => true do
68
+ def app
69
+ MyCrampAction
70
+ end
71
+ it "should respond to a GET request" do
72
+ get("/") do |response|
73
+ response.status.should == 200
74
+ response.headers.should have_key "Content-Type"
75
+ response.should be_matching :status => :ok
76
+ stop # This is important.
77
+ end
78
+ end
79
+ it "should match the body" do
80
+ get("/") do |response|
81
+ response.read_body do
82
+ response.body.should include "Hello, world!" # MockResponse::body returns an Array.
83
+ response.should be_matching :body => "Hello, world!"
84
+ # Note: no call to stop here.
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ In general, I recommend using the 'respond_with' matcher whenever possible; I think it makes the specs more readable because it hides some gory details (for good or bad). But they are useful when you're debugging your cramp application if the failure message doesn't include all the details you need.
@@ -0,0 +1,17 @@
1
+ # Monkey-patch so that even if there is an exception raised in on_start or in on_finish
2
+ # the exception dump is rendered so that:
3
+ # - you can match against it in your spec,
4
+ # - get method doesn't time out waiting for anything to be rendered (may not be true for on_finish).
5
+ #
6
+ module Cramp
7
+ class Action
8
+ alias :old_handle_exception :handle_exception
9
+ def handle_exception(exception)
10
+ if @_state != :init
11
+ handler = ExceptionHandler.new(@env, exception)
12
+ render handler.pretty
13
+ end
14
+ old_handle_exception(exception)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ # respond_to RSpec matcher.
2
+ # See spec/examples for sample usage.
3
+ #
4
+ RSpec::Matchers.define :respond_with do |options = {}|
5
+ match do |response|
6
+ @actual_response = response
7
+ response.matching?(options)
8
+ end
9
+
10
+ failure_message_for_should do
11
+ @actual_response.last_failure_message_for_should
12
+ end
13
+ failure_message_for_should_not do
14
+ @actual_response.last_failure_message_for_should_not
15
+ end
16
+ end
@@ -0,0 +1,135 @@
1
+ module RSpec
2
+ module Cramp
3
+
4
+ # Response from get, post etc. methods called in rspecs.
5
+ class MockResponse
6
+ def initialize(response)
7
+ @status = response[0]
8
+ @headers = response[1]
9
+ @body = response[2]
10
+ end
11
+
12
+ def read_body(max_chunks = 1, &block)
13
+ if @body.is_a? ::Cramp::Body
14
+ stopping = false
15
+ deferred_body = @body
16
+ chunks = []
17
+ deferred_body.each do |chunk|
18
+ chunks << chunk unless stopping
19
+ if chunks.count >= max_chunks
20
+ @body = chunks
21
+ stopping = true
22
+ block.call if block
23
+ EM.next_tick { EM.stop }
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ def [](i)
30
+ [@status, @headers, @body][i]
31
+ end
32
+
33
+ def body
34
+ if @body.is_a? ::Cramp::Body
35
+ raise "Error: Something went wrong or body is not loaded yet (use response.read_body do { })."
36
+ end
37
+ @body
38
+ end
39
+
40
+ def headers
41
+ @headers
42
+ end
43
+
44
+ def status
45
+ @status
46
+ end
47
+
48
+ def matching?(match_options)
49
+ expected_status = match_options.delete(:status)
50
+ expected_header = match_options.delete(:headers)
51
+ expected_body = match_options.delete(:body)
52
+ expected_chunks = match_options.delete(:chunks)
53
+ raise "Unsupported match option" unless match_options.empty?
54
+ matching_status?(expected_status) && matching_headers?(expected_header) && matching_body?(expected_body) &&
55
+ matching_chunks?(expected_chunks)
56
+ end
57
+
58
+ def last_failure_message_for_should
59
+ # TODO Better failure message showing the specific mismatches that made it fail.
60
+ "expected #{@failure_info[:expected]} in #{@failure_info[:what].to_s} but got: #{@failure_info[:actual]}"
61
+ end
62
+ def last_failure_message_for_should_not
63
+ # TODO Better failure message showing the specific successful matches that made it fail.
64
+ "expected response not to match the conditions but got: #{[@status, @headers, @body].inspect}"
65
+ end
66
+
67
+ private
68
+
69
+ def matching_response_element?(what, actual, expected)
70
+ is_match = if expected.nil?
71
+ true # No expectation set.
72
+ elsif actual.nil?
73
+ false
74
+ elsif expected.is_a? Regexp
75
+ actual.to_s.match(expected)
76
+ elsif expected.is_a? Integer
77
+ actual.to_i == expected
78
+ elsif expected.is_a? String
79
+ actual.to_s == expected
80
+ else
81
+ raise "Unsupported type"
82
+ end
83
+ @failure_info = is_match ? {} : {:what => what, :actual => actual, :expected => format_expected(expected)}
84
+ is_match
85
+ end
86
+
87
+ def resolve_status(status)
88
+ case status
89
+ when :ok then /^2[0-9][0-9]$/
90
+ when :error then /^[^2][0-9][0-9]$/
91
+ else status
92
+ end
93
+ end
94
+
95
+ def format_expected(expected)
96
+ expected.is_a?(Regexp) ? "/#{expected.source}/" : expected.inspect
97
+ end
98
+
99
+ def matching_status?(expected_status)
100
+ matching_response_element?(:status, @status, resolve_status(expected_status))
101
+ end
102
+
103
+ def matching_header_values?(expected_header)
104
+ expected_header.find do |ek, ev|
105
+ @headers.find { |ak, av| matching_response_element?(:headers, ak, ek) && !matching_response_element?(:headers, av, ev) } != nil
106
+ end == nil
107
+ end
108
+
109
+ def matching_header_keys?(expected_header)
110
+ is_match = @headers.keys.find do |actual|
111
+ expected_header.keys.find {|expected| matching_response_element?(:headers, actual, expected)} != nil
112
+ end != nil
113
+ @failure_info = is_match ? {} : {:what => :headers, :actual => @headers.keys.inspect, :expected => expected_header.keys.inspect}
114
+ is_match
115
+ end
116
+
117
+ def matching_headers?(expected_header)
118
+ expected_header.nil? ||
119
+ (matching_header_keys?(expected_header) && matching_header_values?(expected_header))
120
+ end
121
+
122
+ def matching_body?(expected_body)
123
+ actual_body = @body.is_a?(Array) ? @body.join("") : @body
124
+ matching_response_element?(:body, actual_body, expected_body)
125
+ end
126
+
127
+ def matching_chunks?(expected_chunks)
128
+ expected_chunks.nil? || (@body.is_a?(Array) &&
129
+ @body.zip(expected_chunks).find do |actual, expected|
130
+ !matching_response_element?(:chunks, actual, expected)
131
+ end.nil?)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,126 @@
1
+ module RSpec
2
+ module Cramp
3
+ # Usage:
4
+ #
5
+ # describe MyAction, :cramp => true do
6
+ # def app
7
+ # MyAction
8
+ # end
9
+ #
10
+ # it "should render home page" do
11
+ # get("/").should respond_with :status => :ok, :body => "Hello, world!"
12
+ # end
13
+ # end
14
+ #
15
+ shared_context "given a Cramp application", :cramp => true do
16
+
17
+ # In your describe block using :cramp => true, define a method called 'app' returning an async Rack application.
18
+ # Example:
19
+ #
20
+ # def app
21
+ # HelloWorldAction
22
+ # end
23
+
24
+ # Request helper method.
25
+ #
26
+ def request(method, path, options = {}, &block)
27
+ raise "Unsupported request method" unless [:get, :post, :delete, :put].include?(method)
28
+ if block
29
+ async_request(method, path, options, &block)
30
+ else
31
+ sync_request(method, path, options)
32
+ end
33
+ end
34
+
35
+ # GET helper method.
36
+ #
37
+ def get(path, options = {}, &block)
38
+ request(:get, path, options, &block)
39
+ end
40
+
41
+ # POST helper method.
42
+ #
43
+ def post(path, options = {}, &block)
44
+ request(:post, path, options, &block)
45
+ end
46
+
47
+ # DELETE helper method.
48
+ #
49
+ def delete(path, options = {}, &block)
50
+ request(:delete, path, options, &block)
51
+ end
52
+
53
+ # PUT helper method.
54
+ #
55
+ def put(path, options = {}, &block)
56
+ request(:put, path, options, &block)
57
+ end
58
+
59
+ # Use it if using a block version of a request helper method.
60
+ # See spec/examples/low_level_spec.rb for examples.
61
+ #
62
+ def stop
63
+ EM.stop
64
+ end
65
+
66
+ # You can change the default timeout by overriding this method.
67
+ #
68
+ def default_timeout
69
+ 3
70
+ end
71
+
72
+
73
+ private
74
+
75
+ before(:all) do
76
+ @request = Rack::MockRequest.new(app)
77
+ end
78
+
79
+ def async_request(method, path, options, &block)
80
+ callback = parse_response(block)
81
+ params = options.delete(:params)
82
+ headers = prepare_http_headers(options.delete(:headers) || {}).
83
+ merge('async.callback' => callback).
84
+ merge(params ? {:params => params} : {})
85
+ timeout_secs = options.delete(:timeout) || default_timeout
86
+ begin
87
+ timeout(timeout_secs) do
88
+ EM.run do
89
+ catch(:async) do
90
+ result = @request.send(method, path, headers)
91
+ callback.call([result.status, result.header, "Something went wrong"])
92
+ end
93
+ end
94
+ end
95
+ rescue Timeout::Error => e
96
+ raise Timeout::Error.new(e.message + " (No render call in action?)")
97
+ end
98
+ end
99
+
100
+ def sync_request(method, path, options)
101
+ max_chunks = options.delete(:max_chunks) || 1
102
+ response = nil
103
+ async_request(method, path, options) do |result|
104
+ if result.status.between?(200, 299)
105
+ result.read_body(max_chunks)
106
+ else
107
+ EM.next_tick { EM.stop }
108
+ end
109
+ response = result
110
+ end
111
+ response
112
+ end
113
+
114
+ def parse_response(block)
115
+ proc do |result|
116
+ response = MockResponse.new(result)
117
+ block.call(response)
118
+ end
119
+ end
120
+
121
+ def prepare_http_headers(headers)
122
+ headers.inject({}) {|acc, (k,v)| acc["HTTP_#{k.upcase.gsub("-", "_")}"] = v; acc}
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,6 @@
1
+ require 'cramp'
2
+
3
+ require 'rspec/cramp/extensions/cramp/action'
4
+ require 'rspec/cramp/matchers/respond_with'
5
+ require 'rspec/cramp/mock_response'
6
+ require 'rspec/cramp/shared_context'
@@ -0,0 +1,31 @@
1
+ require File.join(File.dirname(__FILE__), "../spec_helper")
2
+ require File.join(File.dirname(__FILE__), "sample_actions")
3
+
4
+ describe HelloWorld, :cramp => true do
5
+ def app
6
+ HelloWorld
7
+ end
8
+
9
+ it "should respond to a GET request" do
10
+ # Variaus ways to match a response code.
11
+ get("/").should respond_with :status => :ok # Matches responses from 200 to 299.
12
+ get("/").should respond_with :status => 200 # Matches only 200.
13
+ get("/").should respond_with :status => "200" # Same as above.
14
+ get("/").should respond_with :status => /^2.*/ # Matches response codes starting with 2.
15
+ get("/").should_not respond_with :status => :error # Matches any HTTP error.
16
+ end
17
+
18
+ it "should respond with text starting with 'Hello'" do
19
+ get("/").should respond_with :body => /^Hello.*/
20
+ end
21
+
22
+ it "should respond with 'Hello, world!'" do
23
+ get("/").should respond_with :body => "Hello, world!"
24
+ end
25
+
26
+ it "should respond with html" do
27
+ get("/").should respond_with :headers => {"Content-Type" => "text/html"}
28
+ get("/").should_not respond_with :headers => {"Content-Type" => "text/plain"}
29
+ get("/").should_not respond_with :headers => {"Unexpected-Header" => /.*/}
30
+ end
31
+ end
@@ -0,0 +1,26 @@
1
+ require File.join(File.dirname(__FILE__), "../spec_helper")
2
+ require File.join(File.dirname(__FILE__), "sample_actions")
3
+
4
+ describe "Error handling", :cramp => true do
5
+ def app
6
+ HttpRouter.new do
7
+ add('/error_before_start').to ErrorBeforeStart
8
+ add('/error_on_start').to ErrorOnStart
9
+ add('/error_on_finish').to ErrorOnFinish
10
+ end
11
+ end
12
+
13
+ it "should handle error in before_start handler" do
14
+ get("/error_before_start").should respond_with :status => 500
15
+ end
16
+
17
+ it "should handle error in on_start handler" do
18
+ # Headers were already sent by the time the exception was raised.
19
+ get("/error_on_start").should respond_with :body => /.*Error in on_start.*/, :status => 200
20
+ end
21
+
22
+ it "should handle error in on_finish handler" do
23
+ # Headers were already sent by the time the exception was raised.
24
+ get("/error_on_finish").should respond_with :body => /.*Error in on_finish.*/, :status => 200
25
+ end
26
+ end
@@ -0,0 +1,25 @@
1
+ require File.join(File.dirname(__FILE__), "../spec_helper")
2
+ require File.join(File.dirname(__FILE__), "sample_actions")
3
+
4
+ describe CustomHeader, :cramp => true do
5
+ def app
6
+ CustomHeader
7
+ end
8
+
9
+ it "should render the value of the custom header" do
10
+ get("/", :headers => {"Custom-Header" => "SAMPLE VALUE"}).should respond_with :body => /^SAMPLE VALUE$/
11
+ get("/", :headers => {"Custom-Header" => "SAMPLE VALUE"}).should respond_with :body => "SAMPLE VALUE"
12
+ end
13
+
14
+ it "should include the custom header in response headers" do
15
+ # Exact match using string & regex.
16
+ get("/", :headers => {"Custom-Header" => "SAMPLE VALUE"}).should respond_with :headers => {"Custom-Header" => "SAMPLE VALUE"}
17
+ get("/", :headers => {"Custom-Header" => "SAMPLE VALUE"}).should respond_with :headers => {"Custom-Header" => /^SAMPLE VALUE$/}
18
+
19
+ # Header field names are case insensitive - use regex match.
20
+ get("/", :headers => {"Custom-Header" => "SAMPLE VALUE"}).should respond_with :headers => {/Custom\-Header/i => "SAMPLE VALUE"}
21
+
22
+ # Negative match.
23
+ get("/", :headers => {"Custom-Header" => "SAMPLE VALUE"}).should_not respond_with :headers => {"Custom-Header" => "ANOTHER VALUE"}
24
+ end
25
+ end
@@ -0,0 +1,26 @@
1
+ require File.join(File.dirname(__FILE__), "../spec_helper")
2
+ require File.join(File.dirname(__FILE__), "sample_actions")
3
+
4
+ describe HelloWorld, :cramp => true do
5
+ def app
6
+ HelloWorld
7
+ end
8
+
9
+ it "should respond to a GET request" do
10
+ get("/") do |response|
11
+ response.status.should == 200
12
+ response.headers.should have_key "Content-Type"
13
+ response.should be_matching :status => :ok
14
+ stop # This is important.
15
+ end
16
+ end
17
+ it "should match the body" do
18
+ get("/") do |response|
19
+ response.read_body do
20
+ response.body.should include "Hello, world!" # MockResponse::body returns an Array.
21
+ response.should be_matching :body => "Hello, world!"
22
+ # Note: no call to stop here.
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,42 @@
1
+ class HelloWorld < Cramp::Action
2
+ def start
3
+ render "Hello, world!"
4
+ finish
5
+ end
6
+ end
7
+
8
+ class ErrorOnStart < Cramp::Action
9
+ on_start :raise_error
10
+ def raise_error
11
+ raise "Error in on_start"
12
+ end
13
+ end
14
+
15
+ class ErrorBeforeStart < Cramp::Action
16
+ before_start :raise_error
17
+ def raise_error
18
+ raise "Error in before_start"
19
+ end
20
+ end
21
+
22
+ class ErrorOnFinish < Cramp::Action
23
+ on_start :just_finish
24
+ on_finish :raise_error
25
+ def just_finish
26
+ finish
27
+ end
28
+ def raise_error
29
+ raise "Error in on_finish"
30
+ end
31
+ end
32
+
33
+ class CustomHeader < Cramp::Action
34
+ on_start :render_custom_header
35
+ def respond_with
36
+ [200, {'Content-Type' => 'text/html', 'Custom-Header' => @env["HTTP_CUSTOM_HEADER"]}]
37
+ end
38
+ def render_custom_header
39
+ render @env["HTTP_CUSTOM_HEADER"]
40
+ finish
41
+ end
42
+ end
File without changes