From 9507f5dcc90e22a6355d048f7fe00476e852889f Mon Sep 17 00:00:00 2001 From: Jamis Buck Date: Tue, 28 Feb 2006 18:15:46 +0000 Subject: Add ActionController::IntegrationTest to allow high-level testing of the way the controllers and routes all work together git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@3701 5ecf4fe2-1ee6-0310-87b1-e25e094e27de --- actionpack/CHANGELOG | 2 + .../lib/action_controller/integration_test.rb | 423 +++++++++++++++++++++ 2 files changed, 425 insertions(+) create mode 100644 actionpack/lib/action_controller/integration_test.rb (limited to 'actionpack') diff --git a/actionpack/CHANGELOG b/actionpack/CHANGELOG index 92f18c4213..d198eaf05e 100644 --- a/actionpack/CHANGELOG +++ b/actionpack/CHANGELOG @@ -1,5 +1,7 @@ *SVN* +* Add ActionController::IntegrationTest to allow high-level testing of the way the controllers and routes all work together [Jamis Buck] + * Added support to AssetTagHelper#javascript_include_tag for having :defaults appear anywhere in the list, so you can now make one call ala javascript_include_tag(:defaults, "my_scripts") or javascript_include_tag("my_scripts", :defaults) depending on how you want the load order #3506 [Bob Silva] * Added support for visual effects scoped queues to the visual_effect helper #3530 [Abdur-Rahman Advany] diff --git a/actionpack/lib/action_controller/integration_test.rb b/actionpack/lib/action_controller/integration_test.rb new file mode 100644 index 0000000000..7c7f19a238 --- /dev/null +++ b/actionpack/lib/action_controller/integration_test.rb @@ -0,0 +1,423 @@ +require 'dispatcher' +require 'stringio' +require 'uri' + +module ActionController + module Integration + + # An integration Session instance represents a set of requests and responses + # performed sequentially by some virtual user. Becase you can instantiate + # multiple sessions and run them side-by-side, you can also mimic (to some + # limited extent) multiple simultaneous users interacting with your system. + # + # Typically, you will instantiate a new session using IntegrationTest#open_session, + # rather than instantiating Integration::Session directly. + class Session + include Test::Unit::Assertions + include ActionController::TestProcess + include ActionController::Routing::NamedRoutes + + # The integer HTTP status code of the last request. + attr_reader :status + + # The status message that accompanied the status code of the last request. + attr_reader :status_message + + # The URI of the last request. + attr_reader :path + + # The hostname used in the last request. + attr_reader :host + + # A map of the cookies returned by the last response, and which will be + # sent with the next request. + attr_reader :cookies + + # A map of the headers returned by the last response. + attr_reader :headers + + # A reference to the controller instance used by the last request. + attr_reader :controller + + # A reference to the request instance used by the last request. + attr_reader :request + + # A reference to the response instance used by the last request. + attr_reader :response + + # Create an initialize a new Session instance. + def initialize + reset! + end + + # Resets the instance. This can be used to reset the state information + # in an existing session instance, so it can be used from a clean-slate + # condition. + # + # session.reset! + def reset! + @status = @path = @host = @headers = nil + @result = @status_message = nil + @https = false + @cookies = {} + @controller = @request = @response = nil + + initialize_url_writer + end + + # Specify whether or not the session should mimic a secure HTTPS request. + # + # session.https! + # session.https!(false) + def https!(flag=true) + @https = flag + initialize_url_writer + end + + # Return +true+ if the session is mimicing a secure HTTPS request. + # + # if session.https? + # ... + # end + def https? + @https + end + + # Set the host name to use in the next request. + # + # session.host! "www.example.test" + def host!(name) + @host = name + initialize_url_writer + end + + # Follow a single redirect response. If the last response was not a + # redirect, an exception will be raised. Otherwise, the redirect is + # performed on the location header. + def follow_redirect! + raise "not a redirect! #{@status} #{@status_message}" unless redirect? + get(interpret_uri(headers["location"].first)) + end + + # Performs a GET request, following any subsequent redirect. Note that + # the redirects are followed until the response is not a redirect--this + # means you may run into an infinite loop if your redirect loops back to + # itself. + def get_via_redirect(path, args={}) + get path, args + follow_redirect! while redirect? + end + + # Performs a POST request, following any subsequent redirect. This is + # vulnerable to infinite loops, the same as #get_via_redirect. + def post_via_redirect(path, args={}) + post path, args + follow_redirect! while redirect? + end + + # Returns +true+ if the last response was a redirect. + def redirect? + status/100 == 3 + end + + # Performs a GET request with the given parameters. The parameters may + # be +nil+, a Hash, or a string that is appropriately encoded + # (application/x-www-form-urlencoded or multipart/form-data). + def get(path, parameters=nil, headers=nil) + process :get, path, parameters, headers + end + + # Performs a POST request with the given parameters. The parameters may + # be +nil+, a Hash, or a string that is appropriately encoded + # (application/x-www-form-urlencoded or multipart/form-data). + def post(path, parameters=nil, headers=nil) + process :post, path, parameters, headers + end + + # Performs an XMLHttpRequest request with the given parameters, mimicing + # the request environment created by the Prototype library. The parameters + # may be +nil+, a Hash, or a string that is appropriately encoded + # (application/x-www-form-urlencoded or multipart/form-data). + def xml_http_request(path, parameters=nil, headers=nil) + headers = (headers || {}).merge("X-Requested-With" => "XMLHttpRequest") + post(path, parameters, headers) + end + + # Returns the URL for the given options, according to the rules specified + # in the application's routes. + def url_for(options) + @rewriter.rewrite(options) + end + + private + + class MockCGI < CGI #:nodoc: + attr_accessor :stdinput, :stdoutput, :env_table + + def initialize(env, input=nil) + self.env_table = env + self.stdinput = StringIO.new(input || "") + self.stdoutput = StringIO.new + + super() + end + end + + # Tailors the session based on the given URI, setting the HTTPS value + # and the hostname. + def interpret_uri(path) + location = URI.parse(path) + https! URI::HTTPS === location + host! location.host + location.query ? "#{location.path}?#{location.query}" : location.path + end + + # Performs the actual request. + def process(method, path, parameters=nil, headers=nil) + data = requestify(parameters) + path = interpret_uri(path) if path =~ %r{://} + path = "/#{path}" unless path[0] == ?/ + @path = path + env = {} + + if method == :get + env["QUERY_STRING"] = data + data = nil + end + + env.update( + "REQUEST_METHOD" => method.to_s.upcase, + "REQUEST_URI" => path, + "HTTP_HOST" => host, + "SERVER_PORT" => (https? ? "443" : "80"), + "CONTENT_TYPE" => "application/x-www-form-urlencoded", + "CONTENT_LENGTH" => data ? data.length.to_s : nil, + "HTTP_COOKIE" => encode_cookies, + "HTTPS" => https? ? "on" : "off" + ) + + (headers || {}).each do |key, value| + key = key.to_s.upcase.gsub(/-/, "_") + key = "HTTP_#{key}" unless env.has_key?(key) + env[key] = value + end + + unless ActionController::Base.respond_to?(:clear_last_instantiation!) + ActionController::Base.send(:include, ControllerCapture) + end + + ActionController::Base.clear_last_instantiation! + + cgi = MockCGI.new(env, data) + Dispatcher.dispatch(cgi, ActionController::CgiRequest::DEFAULT_SESSION_OPTIONS, cgi.stdoutput) + @result = cgi.stdoutput.string + + @controller = ActionController::Base.last_instantiation + @request = @controller.request + @response = @controller.response + + parse_result + end + + # Parses the result of the response and extracts the various values, + # like cookies, status, headers, etc. + def parse_result + headers, result_body = @result.split(/\r\n\r\n/, 2) + + @headers = Hash.new { |h,k| h[k] = [] } + headers.each_line do |line| + key, value = line.strip.split(/:\s*/, 2) + @headers[key.downcase] << value + end + + (@headers['set-cookie'] || [] ).each do |string| + name, value = string.match(/^(.*?)=(.*?);/)[1,2] + @cookies[name] = value + end + + @status, @status_message = @headers["status"].first.split(/ /) + @status = @status.to_i + end + + # Encode the cookies hash in a format suitable for passing to a + # request. + def encode_cookies + cookies.inject("") do |string, (name, value)| + string << "#{name}=#{value}; " + end + end + + # Initialize the URL writer object that will be used to generate + # URL's. + def initialize_url_writer + cgi = MockCGI.new('REQUEST_METHOD' => "GET", + 'QUERY_STRING' => "", + "REQUEST_URI" => "/", + "HTTP_HOST" => host, + "SERVER_PORT" => https? ? "80" : "443", + "HTTPS" => https? ? "on" : "off") + @rewriter = ActionController::UrlRewriter.new(ActionController::CgiRequest.new(cgi), {}) + end + + # Convert the given parameters to a request string. The parameters may + # be a string, +nil+, or a Hash. + def requestify(parameters) + if Hash === parameters + parameters.empty? ? nil : + parameters.map { |k,v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join("&") + else + parameters + end + end + + end + + # A module used to extend ActionController::Base, so that integration tests + # can capture the controller used to satisfy a request. + module ControllerCapture #:nodoc: + def self.included(base) + base.extend(ClassMethods) + base.class_eval do + class < people(:jamis).username, + # :password => people(:jamis).password + # follow_redirect! + # assert_equal 200, status + # assert_equal "/home", path + # end + # end + # + # However, you can also have multiple session instances open per test, and + # even extend those instances with assertions and methods to create a very + # powerful testing DSL that is specific for your application. You can even + # reference any named routes you happen to have defined! + # + # require "#{File.dirname(__FILE__)}/test_helper" + # require "integration_test" + # + # class AdvancedTest < ActionController::IntegrationTest + # fixtures :people, :rooms + # + # def test_login_and_speak + # jamis, david = login(:jamis), login(:david) + # room = rooms(:office) + # + # jamis.enter(room) + # jamis.speak(room, "anybody home?") + # + # david.enter(room) + # david.speak(room, "hello!") + # end + # + # private + # + # module CustomAssertions + # def enter(room) + # # reference a named route, for maximum internal consistency! + # get(room_url(:id => room.id)) + # assert(...) + # ... + # end + # + # def speak(room, message) + # xml_http_request "/say/#{room.id}", :message => message + # assert(...) + # ... + # end + # end + # + # def login(who) + # open_session do |sess| + # sess.extend(CustomAssertions) + # who = people(who) + # sess.post "/login", :username => who.username, + # :password => who.password + # assert(...) + # end + # end + # end + class IntegrationTest < Test::Unit::TestCase + # Work around test/unit's requirement that every subclass of TestCase have + # at least one test method. Note that this implementation extends to all + # subclasses, as well, so subclasses of IntegrationTest may also exist + # without any test methods. + def run(*args) #:nodoc: + return if @method_name == :default_test + super + end + + # Reset the current session. This is useful for testing multiple sessions + # in a single test case. + def reset! + @integration_session = open_session + end + + %w(get post cookies assigns).each do |method| + define_method(method) do |*args| + @integration_session.send(method, *args) + end + end + + # Open a new session instance. If a block is given, the new session is + # yielded to the block before being returned. + # + # session = open_session do |sess| + # sess.extend(CustomAssertions) + # end + # + # By default, a single session is automatically created for you, but you + # can use this method to open multiple sessions that ought to be tested + # simultaneously. + def open_session + session = Integration::Session.new + yield session if block_given? + session + end + + # Delegate unhandled messages to the current session instance. + def method_missing(sym, *args, &block) + reset! unless @integration_session + @integration_session.send(sym, *args, &block) + end + end +end \ No newline at end of file -- cgit v1.2.3