require 'json' class AssertResponse # to allow checking against a parsed json object # the code is instance_exec'd in the context of this # object. you may call the assert- methods here (even without assert- # prefix and you have the json object by calling the json method class AssertJSON def initialize(test_obj, json_string) @test_obj = test_obj @json_obj = JSON.parse(json_string) end def json @json_obj end def method_missing(meth, *args, &code) if @test_obj.respond_to? meth @test_obj.send meth, *args, &code elsif @test_obj.respond_to? :"assert_#{meth}" @test_obj.send :"assert_#{meth}", *args, &code else @test_obj.send :method_missing, *args.unshift(meth), &code end end end # error class, raised when a 404 status but another status was expected class HttpError < Exception ; end class Status404 < HttpError ; end # Hash with names for the http 1.1 status codes HTTP_STATUS_CODES = { :ok => 200, :not_found => 404, :created => 201, :bad_request => 400, :unauthorized => 401, :forbidden => 403, :timeout => 408, :gateway_timeout => 504, :bad_gateway => 502, :conflict => 409, :gone => 410, :too_many_requests => 429, :upgrade_required => 426, :teapot => 418, :no_response => 444, :bandwidth_limit_exceeded => 509, :maintenance => 503, :insufficient_storage => 507, :http_version_not_supported => 505, :payment_required => 402, :not_modified => 304, :see_other => 303, :found => 302, :moved => 301, :reset => 205, :reload => 205, :no_content => 204, :too_large => 413, :uri_too_long => 414, :unsupported_media_type => 415, :not_implemented => 501, :error => 500 } # Hash to collection available content-types (prefilled with the Rack::Mime::MIME_TYPES) # # @see AssertResponse#method_missing CONTENT_TYPES = {} # Adds custom content_types to # {AssertResponse::CONTENT_TYPES}. We would then have all the test methods for the content-type # @param [Symbol] name name of the content_type # @param [String] content_type content_type (mime type, eg. "text/html") def AssertResponse.add_content_type(name, content_type) CONTENT_TYPES.update(name => content_type) end # add all Rack::Mime::MIME_TYPES as content_types Rack::Mime::MIME_TYPES.each do |fileext,mime| AssertResponse.add_content_type fileext.sub(/^\./, '').to_sym, mime end # add content_type 'text/plain' as :text AssertResponse.add_content_type :text, 'text/plain' # Adds custom http code to # {AssertResponse::HTTP_STATUS_CODES}. We would then have all the test methods for the status code # @param [Symbol] name name of the http status code # @param [Integer] code code number (eg. 404) def AssertResponse.add_http_status_code(name, code) raise "invalid code" unless code =~ /^[1-9][0-9]{2}$/ HTTP_STATUS_CODES.update(name => code) end # Creates a new {AssertResponse} Object. Usually you will want to +instance_exec+ some code on it. # # @param [Object] delegate_to the test object that has all the +assert_equal+, +assert_match+ and +assert+ methods that we need # @param [MockResponse, Response] response the response object that is checked def initialize(delegate_to, response, request, &code) @delegate_to = delegate_to @response = response @request = request @error = nil check_for_error() end # for each content-type in {AssertResponse::CONTENT_TYPES} these methods are served by method_missing # * +is_[content_type]+ that checks if the response has the content-type # * +[content_type]+ that checks the content-type and matches the body against the given pattern and checks for status 200 # * +not_found_[content_type]+ that checks the content-type, matches the body against the pattern and checks for status 404 # further unknown methods are delegated to +@delegate_to+ def method_missing(meth, *args, &code) case meth.to_s when /^(is|found)_(.+)$/ # is_[content_type] methods if ctype = CONTENT_TYPES[$2.to_sym] ok() return content_type(ctype) end when /^not_found_(.+)$/ # not_found_[content_type] methods if ctype = CONTENT_TYPES[$1.to_sym] content_type ctype return body(args.first, :not_found) end else if ctype = CONTENT_TYPES[meth] # [content_type] methods content_type ctype return body(*args) elsif HTTP_STATUS_CODES[meth] return status HTTP_STATUS_CODES[meth] end end @delegate_to.send meth, *args, &code end # Checks if status is some of the ok-codes. def ok() status /^20[0-9]$/ end # shortcut to the path of the last request def path_info @request.env['PATH_INFO'] end # shortcut to the url of the last request def url @request.url end # shortcut to the query string of the last request def query_string @request.env['QUERY_STRING'] end # this method checks for content_type json and then it has 2 modes # if no code block is given, it acts as "body" # if a code block is given, arg1 is the status and arg2 is ignored # the code is executed in the context of a AssertJSON object, where # you can use the usual assert- methods (even without assert prefix) # and the json method gives you the parsed json object to test against def json(arg1=nil, arg2=nil, &code) content_type CONTENT_TYPES[:json] if code status arg1 file, line, rest = caller[0].split(':', 3) AssertJSON.new(@delegate_to, @response.body).instance_exec(file, line.to_i, &code) elsif arg1 body arg1, arg2 end end # Checks if the status is +status+. For status other then 5xx and error is raised if the response has an error. # For status other than 404 an error Status404 is thrown if response status is 404 # If we do not check for a 4xx or 5xx code an StatusError is raised if such a code is found and the body is given as error message # # @params [Integer, Regexp, Symbol] status the expected status (if it's a symbol it is looked up in HTTP_STATUS_CODES def status(status) return ok() if status.nil? if status.is_a?(Symbol) raise "unknown status #{status}" unless HTTP_STATUS_CODES[status] status = HTTP_STATUS_CODES[status] end look_for_5xx = case status when Regexp ((50.to_s =~ status) || (51.to_s =~ status))? true : false else (status.to_s =~ /^5/) ? true : false end look_for_404 = case status when Regexp (404.to_s =~ status) ? true : false else (status.to_s == "404") ? true : false end look_for_4xx = case status when Regexp ((40.to_s =~ status) || (41.to_s =~ status))? true : false else (status.to_s =~ /^4/) ? true : false end raise_error() unless look_for_5xx or @response.errors.empty? Kernel.raise(Status404, "(#{url}) not found") unless look_for_404 or @response.status != 404 case status when Regexp raise_http_error() if @response.status.to_s =~ /^5/ and !look_for_5xx raise_http_error() if @response.status.to_s =~ /^4/ and !look_for_4xx assert_match(status, @response.status.to_s) else raise_http_error() if @response.status.to_s =~ /^5/ and status.to_s !~ /^5/ raise_http_error() if @response.status.to_s =~ /^4/ and status.to_s !~ /^4/ assert_equal(status, @response.status) end end # Checks if the status is 302, and the header "Location" matches the +pattern+ # # @param [String,Regexp] pattern the pattern to match against. def redirect(pattern) status /^30(2|3|7)$/ header 'Location', pattern end # Checks if the header +key+ exists and / or if it matches +pattern+. # # @param [String] key the key to look for # @param [String,Regexp,:exists] pattern the pattern to match against. +:exists+ just checks for existance of +key+. def header(key, pattern=:exists) if pattern == :exists assert !@response.headers[key].to_s.empty?, "Header '#{key}' not found" elsif pattern.is_a? Regexp assert_match normalize_pattern(pattern), @response.headers[key].to_s else assert_equal pattern.to_s, @response.headers[key].to_s end end # Check if the +Content-Type+ matches +pattern+. Checks also if request is ok (see {#ok}). # # @param [String,Regexp] pattern the pattern to match against. def content_type(pattern) header 'Content-Type', normalize_pattern(pattern) end # Check if body matches +pattern+ and status is +status+. # # @param [String,Regexp] pattern the pattern to match against. # @params [Integer] status the expected status (default 200, see {#ok}) def body(pattern, status=nil) status(status) assert_match normalize_pattern(pattern), @response.body end # Check if an error was raised. # # @param [Class] exception_class also check if error is_a? +exception_class+ # @param [String,Regexp] pattern also check if the error message matches +pattern+ def raises(exception_class=nil, pattern=nil) assert_equal exception_class, @error.class if exception_class assert_match normalize_pattern(pattern), @error.message if pattern assert !@error.nil? end # redirect to get def see_other(pattern) status 303 header 'Location', pattern end # temporary move def found(pattern) status 302 header 'Location', pattern end # permanent move def moved(pattern) status 301 header 'Location', pattern end private def normalize_pattern(pattern) pattern.is_a?(Regexp) ? pattern : /#{Regexp.escape(pattern.to_s)}/i end def check_for_error normalize_error unless @response.errors.to_s.empty? end def normalize_error #p [:l, last_request] #@response message, backtrace = @response.errors.split "\n", 2 backtrace = backtrace.split err_klass, message = message.split '-', 2 @error = Module.const_get(err_klass.strip.to_sym).new(message.strip) @error.set_backtrace backtrace @error end def raise_error() Kernel.raise @error end def raise_http_error() raise HttpError, "#{@response.status} (#{url}) #{@response.body}" end # these methods are included in Rack::Test::Methods to be used in a Test Class # # call assert_response with a code block to use the DSL (methods from {AssertResponse}) # or use a method like assert_response_xxx where xxx is the name of the method from {AssertResponse} you want to call # @see AssertResponse # # @example with Test::Unit, simple # require 'rack/test' # require 'assert-response' # # class TestMe < Test::Unit::Testcase # include Rack::Test::Methods # def app # MyApp # end # # def test_something # get '/works' # assert_response_html "should really work" # end # end # # @example without including +Rack::Test::Methods+ # require 'rack/test' # require 'assert-response' # # class AppWrapper # include Rack::Test::Methods # def app # MyApp # end # end # # class TestMe < Test::Unit::Testcase # include AssertResponse::Methods # # def test_something # server = AppWrapper.new # server.get '/works' # assert_response server.last_response do # html "should really work" # end # end # end # # @example with *Minitest*, simple # require 'rack/test' # require 'assert-response' # include Rack::Test::Methods # # # write your tests here # it "should work" do # get '/works' # assert_response_html "should really work" # end # # @example without including +Rack::Test::Methods+ # require 'rack/test' # require 'assert-response' # # class AppWrapper # include Rack::Test::Methods # def app # MyApp # end # end # # include AssertResponse::Methods # # describe "my app" do # before do # @app = AppWrapper.new # end # # it "should work" do # @app.get '/works' # assert_response(@app.last_response) do # html "should really work" # end # end # end module Methods # route assert_response_ methods to {AssertResponse} # @see AssertResponse def method_missing(meth, *args, &code) if meth.to_s =~ /^assert_response_(.+)$/ AssertResponse.new(self, last_response, last_request).send($1.to_sym, *args, &code) else super end end # creates an {AssertResponse} Object and +instance_exec+ the code (DSL) in it # @see AssertResponse def assert_response(response=last_response, request=last_request, &code) file, line, rest = caller[0].split(':', 3) AssertResponse.new(self, response, request).instance_exec(file, line.to_i, &code) end end end module Rack::Test::Methods include AssertResponse::Methods end