integration.rb 16.7 KB
Newer Older
1 2
require 'stringio'
require 'uri'
3
require 'active_support/core_ext/kernel/singleton_class'
4
require 'active_support/core_ext/object/inclusion'
5
require 'active_support/core_ext/object/try'
J
Joshua Peek 已提交
6
require 'rack/test'
7 8

module ActionDispatch
D
David Heinemeier Hansson 已提交
9
  module Integration #:nodoc:
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
    module RequestHelpers
      # Performs a GET request with the given parameters.
      #
      # - +path+: The URI (as a String) on which you want to perform a GET
      #   request.
      # - +parameters+: The HTTP parameters that you want to pass. This may
      #   be +nil+,
      #   a Hash, or a String that is appropriately encoded
      #   (<tt>application/x-www-form-urlencoded</tt> or
      #   <tt>multipart/form-data</tt>).
      # - +headers+: Additional HTTP headers to pass, as a Hash. The keys will
      #   automatically be upcased, with the prefix 'HTTP_' added if needed.
      #
      # This method returns an Response object, which one can use to
      # inspect the details of the response. Furthermore, if this method was
25
      # called from an ActionDispatch::IntegrationTest object, then that
26 27 28
      # object's <tt>@response</tt> instance variable will point to the same
      # response object.
      #
29 30
      # You can also perform POST, PATCH, PUT, DELETE, and HEAD requests with
      # +#post+, +#patch+, +#put+, +#delete+, and +#head+.
31 32 33 34
      def get(path, parameters = nil, headers = nil)
        process :get, path, parameters, headers
      end

35
      # Performs a POST request with the given parameters. See +#get+ for more
36 37 38 39 40
      # details.
      def post(path, parameters = nil, headers = nil)
        process :post, path, parameters, headers
      end

41 42 43 44 45 46
      # Performs a PATCH request with the given parameters. See +#get+ for more
      # details.
      def patch(path, parameters = nil, headers = nil)
        process :patch, path, parameters, headers
      end

47
      # Performs a PUT request with the given parameters. See +#get+ for more
48 49 50 51 52
      # details.
      def put(path, parameters = nil, headers = nil)
        process :put, path, parameters, headers
      end

53
      # Performs a DELETE request with the given parameters. See +#get+ for
54 55 56 57 58
      # more details.
      def delete(path, parameters = nil, headers = nil)
        process :delete, path, parameters, headers
      end

59
      # Performs a HEAD request with the given parameters. See +#get+ for more
60 61 62 63 64
      # details.
      def head(path, parameters = nil, headers = nil)
        process :head, path, parameters, headers
      end

65 66 67 68 69 70
      # Performs a OPTIONS request with the given parameters. See +#get+ for
      # more details.
      def options(path, parameters = nil, headers = nil)
        process :options, path, parameters, headers
      end

71 72 73
      # Performs an XMLHttpRequest request with the given parameters, mirroring
      # a request from the Prototype library.
      #
74 75 76 77
      # The request_method is +:get+, +:post+, +:patch+, +:put+, +:delete+ or
      # +:head+; the parameters are +nil+, a hash, or a url-encoded or multipart
      # string; the headers are a hash.  Keys are automatically upcased and
      # prefixed with 'HTTP_' if not already.
78 79
      def xml_http_request(request_method, path, parameters = nil, headers = nil)
        headers ||= {}
80 81
        headers['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'
        headers['HTTP_ACCEPT'] ||= [Mime::JS, Mime::HTML, Mime::XML, 'text/xml', Mime::ALL].join(', ')
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116
        process(request_method, path, parameters, headers)
      end
      alias xhr :xml_http_request

      # 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(response.location)
        status
      end

      # Performs a request using the specified method, 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 request_via_redirect(http_method, path, parameters = nil, headers = nil)
        process(http_method, path, parameters, headers)
        follow_redirect! while redirect?
        status
      end

      # Performs a GET request, following any subsequent redirect.
      # See +request_via_redirect+ for more information.
      def get_via_redirect(path, parameters = nil, headers = nil)
        request_via_redirect(:get, path, parameters, headers)
      end

      # Performs a POST request, following any subsequent redirect.
      # See +request_via_redirect+ for more information.
      def post_via_redirect(path, parameters = nil, headers = nil)
        request_via_redirect(:post, path, parameters, headers)
      end

117 118 119 120 121 122
      # Performs a PATCH request, following any subsequent redirect.
      # See +request_via_redirect+ for more information.
      def patch_via_redirect(path, parameters = nil, headers = nil)
        request_via_redirect(:patch, path, parameters, headers)
      end

123 124 125 126 127 128 129 130 131 132 133 134 135
      # Performs a PUT request, following any subsequent redirect.
      # See +request_via_redirect+ for more information.
      def put_via_redirect(path, parameters = nil, headers = nil)
        request_via_redirect(:put, path, parameters, headers)
      end

      # Performs a DELETE request, following any subsequent redirect.
      # See +request_via_redirect+ for more information.
      def delete_via_redirect(path, parameters = nil, headers = nil)
        request_via_redirect(:delete, path, parameters, headers)
      end
    end

136 137
    # An instance of this class represents a set of requests and responses
    # performed sequentially by a test process. Because you can instantiate
138 139 140
    # multiple sessions and run them side-by-side, you can also mimic (to some
    # limited extent) multiple simultaneous users interacting with your system.
    #
141 142 143
    # Typically, you will instantiate a new session using
    # IntegrationTest#open_session, rather than instantiating
    # Integration::Session directly.
144
    class Session
145 146
      DEFAULT_HOST = "www.example.com"

147
      include MiniTest::Assertions
J
Joshua Peek 已提交
148
      include TestProcess, RequestHelpers, Assertions
149

150 151 152
      %w( status status_message headers body redirect? ).each do |method|
        delegate method, :to => :response, :allow_nil => true
      end
153

154 155 156
      %w( path ).each do |method|
        delegate method, :to => :request, :allow_nil => true
      end
157 158

      # The hostname used in the last request.
159 160 161 162
      def host
        @host || DEFAULT_HOST
      end
      attr_writer :host
163 164 165

      # The remote_addr used in the last request.
      attr_accessor :remote_addr
166

167 168 169
      # The Accept header to send.
      attr_accessor :accept

170 171
      # A map of the cookies returned by the last response, and which will be
      # sent with the next request.
172
      def cookies
173
        _mock_session.cookie_jar
174
      end
175 176 177 178 179 180 181 182 183 184

      # 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

185 186 187
      # A running counter of the number of requests processed.
      attr_accessor :request_count

188 189
      include ActionDispatch::Routing::UrlFor

P
Pratik Naik 已提交
190
      # Create and initialize a new Session instance.
191
      def initialize(app)
J
José Valim 已提交
192
        super()
193
        @app = app
194 195 196 197 198 199 200

        # If the app is a Rails app, make url_helpers available on the session
        # This makes app.url_for and app.foo_path available in the console
        if app.respond_to?(:routes) && app.routes.respond_to?(:url_helpers)
          singleton_class.class_eval { include app.routes.url_helpers }
        end

201 202 203
        reset!
      end

204
      remove_method :default_url_options
205 206
      def default_url_options
        { :host => host, :protocol => https? ? "https" : "http" }
207 208
      end

209 210 211 212 213 214 215 216
      # 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!
        @https = false
        @controller = @request = @response = nil
217
        @_mock_session = nil
218
        @request_count = 0
219

220
        self.host        = DEFAULT_HOST
221
        self.remote_addr = "127.0.0.1"
222 223 224
        self.accept      = "text/xml,application/xml,application/xhtml+xml," +
                           "text/html;q=0.9,text/plain;q=0.8,image/png," +
                           "*/*;q=0.5"
J
Jamis Buck 已提交
225

226
        unless defined? @named_routes_configured
J
Jamis Buck 已提交
227 228 229 230
          # the helpers are made protected by default--we make them public for
          # easier access during testing and troubleshooting.
          @named_routes_configured = true
        end
231 232 233 234 235 236
      end

      # Specify whether or not the session should mimic a secure HTTPS request.
      #
      #   session.https!
      #   session.https!(false)
237
      def https!(flag = true)
238
        @https = flag
239 240
      end

P
Pratik Naik 已提交
241
      # Return +true+ if the session is mimicking a secure HTTPS request.
242 243 244 245 246 247 248 249 250 251
      #
      #   if session.https?
      #     ...
      #   end
      def https?
        @https
      end

      # Set the host name to use in the next request.
      #
252
      #   session.host! "www.example.com"
A
Aaron Patterson 已提交
253
      alias :host! :host=
254 255

      private
256 257 258
        def _mock_session
          @_mock_session ||= Rack::MockSession.new(@app, host)
        end
259

260
        # Performs the actual request.
261 262
        def process(method, path, parameters = nil, rack_env = nil)
          rack_env ||= {}
263 264 265 266 267 268
          if path =~ %r{://}
            location = URI.parse(path)
            https! URI::HTTPS === location if location.scheme
            host! location.host if location.host
            path = location.query ? "#{location.path}?#{location.query}" : location.path
          end
269

270 271 272
          unless ActionController::Base < ActionController::Testing
            ActionController::Base.class_eval do
              include ActionController::Testing
273
            end
274 275
          end

276
          hostname, port = host.split(':')
277

278
          env = {
279
            :method => method,
280
            :params => parameters,
281

282
            "SERVER_NAME"     => hostname,
283
            "SERVER_PORT"     => port || (https? ? "443" : "80"),
284 285 286
            "HTTPS"           => https? ? "on" : "off",
            "rack.url_scheme" => https? ? "https" : "http",

287
            "REQUEST_URI"    => path,
288
            "HTTP_HOST"      => host,
289
            "REMOTE_ADDR"    => remote_addr,
290
            "CONTENT_TYPE"   => "application/x-www-form-urlencoded",
291
            "HTTP_ACCEPT"    => accept
292
          }
293

294
          session = Rack::Test::Session.new(_mock_session)
295

296
          env.merge!(rack_env)
297

298 299 300 301 302 303 304 305 306
          # NOTE: rack-test v0.5 doesn't build a default uri correctly
          # Make sure requested path is always a full uri
          uri = URI.parse('/')
          uri.scheme ||= env['rack.url_scheme']
          uri.host   ||= env['SERVER_NAME']
          uri.port   ||= env['SERVER_PORT'].try(:to_i)
          uri += path

          session.request(uri.to_s, env)
307

308
          @request_count += 1
309
          @request  = ActionDispatch::Request.new(session.last_request.env)
310
          response = _mock_session.last_response
311
          @response = ActionDispatch::TestResponse.new(response.status, response.headers, response.body)
312
          @html_document = nil
313

314 315
          @controller = session.last_request.env['action_controller.instance']

316
          return response.status
317 318 319
        end
    end

320
    module Runner
321 322
      include ActionDispatch::Assertions

323
      def app
324
        @app ||= nil
325 326
      end

327 328 329
      # Reset the current session. This is useful for testing multiple sessions
      # in a single test case.
      def reset!
330
        @integration_session = Integration::Session.new(app)
331 332
      end

333
      %w(get post patch put head delete options cookies assigns
334
         xml_http_request xhr get_via_redirect post_via_redirect).each do |method|
335
        define_method(method) do |*args|
336
          reset! unless integration_session
337
          # reset the html_document variable, but only for new get/post calls
338
          @html_document = nil unless method.in?(["cookies", "assigns"])
339
          integration_session.__send__(method, *args).tap do
340 341 342 343 344 345 346 347 348 349 350 351 352 353 354
            copy_session_variables!
          end
        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.
355
      def open_session(app = nil)
356 357
        dup.tap do |session|
          yield session if block_given?
358 359 360 361 362 363
        end
      end

      # Copy the instance variables from the current session instance into the
      # test instance.
      def copy_session_variables! #:nodoc:
364
        return unless integration_session
365
        %w(controller response request).each do |var|
366
          instance_variable_set("@#{var}", @integration_session.__send__(var))
367 368 369
        end
      end

370 371 372
      extend ActiveSupport::Concern
      include ActionDispatch::Routing::UrlFor

373
      def url_options
374 375
        reset! unless integration_session
        integration_session.url_options
376 377
      end

378
      def respond_to?(method, include_private = false)
379
        integration_session.respond_to?(method, include_private) || super
380 381
      end

382 383
      # Delegate unhandled messages to the current session instance.
      def method_missing(sym, *args, &block)
384 385 386
        reset! unless integration_session
        if integration_session.respond_to?(sym)
          integration_session.__send__(sym, *args, &block).tap do
387 388 389 390
            copy_session_variables!
          end
        else
          super
391 392
        end
      end
393 394 395 396 397

      private
        def integration_session
          @integration_session ||= nil
        end
398
    end
399 400
  end

401
  # An integration test spans multiple controllers and actions,
402 403 404 405
  # 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.
  #
406
  # At its simplest, you simply extend <tt>IntegrationTest</tt> and write your tests
407 408
  # using the get/post methods:
  #
409
  #   require "test_helper"
410
  #
411
  #   class ExampleTest < ActionDispatch::IntegrationTest
412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430
  #     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
431
  # reference any named routes you happen to have defined.
432
  #
433
  #   require "test_helper"
434
  #
435
  #   class AdvancedTest < ActionDispatch::IntegrationTest
436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475
  #     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
476
  class IntegrationTest < ActiveSupport::TestCase
477
    include Integration::Runner
478
    include ActionController::TemplateAssertions
479 480 481 482

    @@app = nil

    def self.app
483 484 485 486 487 488
      if !@@app && !ActionDispatch.test_app
        ActiveSupport::Deprecation.warn "Rails application fallback is deprecated " \
          "and no longer works, please set ActionDispatch.test_app", caller
      end

      @@app || ActionDispatch.test_app
489 490 491 492 493 494 495 496 497
    end

    def self.app=(app)
      @@app = app
    end

    def app
      super || self.class.app
    end
498
  end
499
end