integration.rb 17.5 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
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

      # 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.
29 30 31 32
      attr_accessor :host

      # The remote_addr used in the last request.
      attr_accessor :remote_addr
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60

      # 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!
J
Jamis Buck 已提交
61
        @status = @path = @headers = nil
62 63 64 65 66
        @result = @status_message = nil
        @https = false
        @cookies = {}
        @controller = @request = @response = nil
      
67 68
        self.host        = "www.example.test"
        self.remote_addr = "127.0.0.1"
J
Jamis Buck 已提交
69 70 71 72 73 74 75 76 77 78 79

        unless @named_routes_configured
          # install the named routes in this session instance.
          klass = class<<self; self; end
          Routing::NamedRoutes.install(klass)

          # the helpers are made protected by default--we make them public for
          # easier access during testing and troubleshooting.
          klass.send(:public, *Routing::NamedRoutes::Helpers)
          @named_routes_configured = true
        end
80 81 82 83 84 85 86
      end

      # Specify whether or not the session should mimic a secure HTTPS request.
      #
      #   session.https!
      #   session.https!(false)
      def https!(flag=true)
87
        @https = flag        
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111
      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
      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))
112
        status
113 114 115 116 117 118 119 120 121
      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?
122
        status
123 124 125 126 127 128 129
      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?
130
        status
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163
      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)
164
        controller ? controller.url_for(options) : generic_url_rewriter.rewrite(options)
165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184
      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)
J
Jamis Buck 已提交
185
          https! URI::HTTPS === location if location.scheme
186
          host! location.host if location.host
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206
          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,
207
            "REMOTE_ADDR"    => remote_addr,
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234
            "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

235 236 237 238 239
          # Decorate the response with the standard behavior of the TestResponse
          # so that things like assert_response can be used in integration
          # tests.
          @response.extend(TestResponseBehavior)

240
          parse_result
241
          return status
242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271
        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

272 273
        # Get a temporarly URL writer object
        def generic_url_rewriter
274 275 276 277
          cgi = MockCGI.new('REQUEST_METHOD' => "GET",
                            'QUERY_STRING'   => "",
                            "REQUEST_URI"    => "/",
                            "HTTP_HOST"      => host,
278
                            "SERVER_PORT"    => https? ? "443" : "80",
279
                            "HTTPS"          => https? ? "on" : "off")                          
280
          ActionController::UrlRewriter.new(ActionController::CgiRequest.new(cgi), {})
281 282
        end

J
Jamis Buck 已提交
283 284 285 286
        def name_with_prefix(prefix, name)
          prefix ? "#{prefix}[#{name}]" : name.to_s
        end

287 288
        # Convert the given parameters to a request string. The parameters may
        # be a string, +nil+, or a Hash.
J
Jamis Buck 已提交
289
        def requestify(parameters, prefix=nil)
290
          if Hash === parameters
J
Jamis Buck 已提交
291 292 293 294 295
            return nil if parameters.empty?
            parameters.map { |k,v| requestify(v, name_with_prefix(prefix, k)) }.join("&")
          elsif Array === parameters
            parameters.map { |v| requestify(v, name_with_prefix(prefix, "")) }.join("&")
          elsif prefix.nil?
296
            parameters
J
Jamis Buck 已提交
297 298
          else
            "#{CGI.escape(prefix)}=#{CGI.escape(parameters.to_s)}"
299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408
          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 <<self
            alias_method :new_without_capture, :new
            alias_method :new, :new_with_capture
          end
        end
      end

      module ClassMethods
        mattr_accessor :last_instantiation

        def clear_last_instantiation!
          self.last_instantiation = nil
        end
    
        def new_with_capture(*args)
          self.last_instantiation ||= new_without_capture(*args)
        end
      end
    end
  end

  # An IntegrationTest is one that spans multiple controllers and actions,
  # tying them all together to ensure they work together as expected. It tests
  # more completely than either unit or functional tests do, exercising the
  # entire stack, from the dispatcher to the database.
  #
  # At its simplest, you simply extend IntegrationTest and write your tests
  # using the get/post methods:
  #
  #   require "#{File.dirname(__FILE__)}/test_helper"
  #   require "integration_test"
  #
  #   class ExampleTest < ActionController::IntegrationTest
  #     fixtures :people
  #
  #     def test_login
  #       # get the login page
  #       get "/login"
  #       assert_equal 200, status
  #
  #       # post the login and follow through to the home page
  #       post "/login", :username => 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
409 410 411 412 413 414 415 416
    # Work around a bug in test/unit caused by the default test being named
    # as a symbol (:default_test), which causes regex test filters
    # (like "ruby test.rb -n /foo/") to fail because =~ doesn't work on
    # symbols.
    def initialize(name) #:nodoc:
      super(name.to_s)
    end

417 418 419 420 421
    # 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:
422
      return if @method_name == "default_test"
423 424 425
      super   
    end

426 427 428 429 430 431 432
    # Because of how use_instantiated_fixtures and use_transactional_fixtures
    # are defined, we need to treat them as special cases. Otherwise, users
    # would potentially have to set their values for both Test::Unit::TestCase
    # ActionController::IntegrationTest, since by the time the value is set on
    # TestCase, IntegrationTest has already been defined and cannot inherit
    # changes to those variables. So, we make those two attributes copy-on-write.

433
    class << self
434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456
      def use_transactional_fixtures=(flag) #:nodoc:
        @_use_transactional_fixtures = true
        @use_transactional_fixtures = flag
      end

      def use_instantiated_fixtures=(flag) #:nodoc:
        @_use_instantiated_fixtures = true
        @use_instantiated_fixtures = flag
      end

      def use_transactional_fixtures #:nodoc:
        @_use_transactional_fixtures ?
          @use_transactional_fixtures :
          superclass.use_transactional_fixtures
      end

      def use_instantiated_fixtures #:nodoc:
        @_use_instantiated_fixtures ?
          @use_instantiated_fixtures :
          superclass.use_instantiated_fixtures
      end
    end

457 458 459 460 461 462 463 464
    # 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|
J
Jamis Buck 已提交
465 466 467 468
        reset! unless @integration_session
        returning @integration_session.send(method, *args) do
          copy_session_variables!
        end
469 470 471 472 473 474 475 476 477 478 479 480 481 482 483
      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
J
Jamis Buck 已提交
484 485

      # delegate the fixture accessors back to the test instance
J
Jamis Buck 已提交
486
      extras = Module.new { attr_accessor :delegate, :test_result }
J
Jamis Buck 已提交
487 488 489
      self.class.fixture_table_names.each do |table_name|
        name = table_name.tr(".", "_")
        next unless respond_to?(name)
J
Jamis Buck 已提交
490
        extras.send(:define_method, name) { |*args| delegate.send(name, *args) }
J
Jamis Buck 已提交
491 492
      end

493
      # delegate add_assertion to the test case
J
Jamis Buck 已提交
494 495 496 497
      extras.send(:define_method, :add_assertion) { test_result.add_assertion }
      session.extend(extras)
      session.delegate = self
      session.test_result = @_result
498

499 500 501 502
      yield session if block_given?
      session
    end

J
Jamis Buck 已提交
503 504 505 506 507 508 509 510 511
    # Copy the instance variables from the current session instance into the
    # test instance.
    def copy_session_variables! #:nodoc:
      return unless @integration_session
      %w(controller response request).each do |var|
        instance_variable_set("@#{var}", @integration_session.send(var))
      end
    end

512 513 514
    # Delegate unhandled messages to the current session instance.
    def method_missing(sym, *args, &block)
      reset! unless @integration_session
J
Jamis Buck 已提交
515 516 517
      returning @integration_session.send(sym, *args, &block) do
        copy_session_variables!
      end
518 519
    end
  end
520
end