integration.rb 20.2 KB
Newer Older
1 2 3 4 5 6
require "stringio"
require "uri"
require "active_support/core_ext/kernel/singleton_class"
require "active_support/core_ext/object/try"
require "rack/test"
require "minitest"
7

8
require "action_dispatch/testing/request_encoder"
9

10
module ActionDispatch
D
David Heinemeier Hansson 已提交
11
  module Integration #:nodoc:
12
    module RequestHelpers
13 14
      # Performs a GET request with the given parameters. See +#process+ for more
      # details.
15 16
      def get(path, **args)
        process(:get, path, **args)
17 18
      end

19
      # Performs a POST request with the given parameters. See +#process+ for more
20
      # details.
21 22
      def post(path, **args)
        process(:post, path, **args)
23 24
      end

25
      # Performs a PATCH request with the given parameters. See +#process+ for more
26
      # details.
27 28
      def patch(path, **args)
        process(:patch, path, **args)
29 30
      end

31
      # Performs a PUT request with the given parameters. See +#process+ for more
32
      # details.
33 34
      def put(path, **args)
        process(:put, path, **args)
35 36
      end

37
      # Performs a DELETE request with the given parameters. See +#process+ for
38
      # more details.
39 40
      def delete(path, **args)
        process(:delete, path, **args)
41 42
      end

43
      # Performs a HEAD request with the given parameters. See +#process+ for more
44
      # details.
45
      def head(path, *args)
46
        process(:head, path, *args)
47 48 49 50 51 52 53 54 55 56 57 58
      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(response.location)
        status
      end
    end

59 60
    # An instance of this class represents a set of requests and responses
    # performed sequentially by a test process. Because you can instantiate
61 62 63
    # multiple sessions and run them side-by-side, you can also mimic (to some
    # limited extent) multiple simultaneous users interacting with your system.
    #
64 65 66
    # Typically, you will instantiate a new session using
    # IntegrationTest#open_session, rather than instantiating
    # Integration::Session directly.
67
    class Session
68 69
      DEFAULT_HOST = "www.example.com"

70
      include Minitest::Assertions
71
      include TestProcess, RequestHelpers, Assertions
72

73
      %w( status status_message headers body redirect? ).each do |method|
74
        delegate method, to: :response, allow_nil: true
75
      end
76

77
      %w( path ).each do |method|
78
        delegate method, to: :request, allow_nil: true
79
      end
80 81

      # The hostname used in the last request.
82 83 84 85
      def host
        @host || DEFAULT_HOST
      end
      attr_writer :host
86 87 88

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

90 91 92
      # The Accept header to send.
      attr_accessor :accept

93 94
      # A map of the cookies returned by the last response, and which will be
      # sent with the next request.
95
      def cookies
96
        _mock_session.cookie_jar
97
      end
98 99 100 101 102 103 104 105 106 107

      # 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

108 109 110
      # A running counter of the number of requests processed.
      attr_accessor :request_count

111 112
      include ActionDispatch::Routing::UrlFor

P
Pratik Naik 已提交
113
      # Create and initialize a new Session instance.
114
      def initialize(app)
J
José Valim 已提交
115
        super()
116
        @app = app
117

118 119 120
        reset!
      end

121 122 123 124
      def url_options
        @url_options ||= default_url_options.dup.tap do |url_options|
          url_options.reverse_merge!(controller.url_options) if controller

125
          if @app.respond_to?(:routes)
126 127 128
            url_options.reverse_merge!(@app.routes.default_url_options)
          end

129
          url_options.reverse_merge!(host: host, protocol: https? ? "https" : "http")
130
        end
131 132
      end

133 134 135 136 137 138 139 140
      # 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
141
        @_mock_session = nil
142
        @request_count = 0
143
        @url_options = nil
144

145
        self.host        = DEFAULT_HOST
146
        self.remote_addr = "127.0.0.1"
147 148
        self.accept      = "text/xml,application/xml,application/xhtml+xml," \
                           "text/html;q=0.9,text/plain;q=0.8,image/png," \
149
                           "*/*;q=0.5"
J
Jamis Buck 已提交
150

151
        unless defined? @named_routes_configured
J
Jamis Buck 已提交
152 153 154 155
          # the helpers are made protected by default--we make them public for
          # easier access during testing and troubleshooting.
          @named_routes_configured = true
        end
156 157 158 159 160 161
      end

      # Specify whether or not the session should mimic a secure HTTPS request.
      #
      #   session.https!
      #   session.https!(false)
162
      def https!(flag = true)
163
        @https = flag
164 165
      end

166
      # Returns +true+ if the session is mimicking a secure HTTPS request.
167 168 169 170 171 172 173 174
      #
      #   if session.https?
      #     ...
      #   end
      def https?
        @https
      end

175
      # Performs the actual request.
176
      #
177 178
      # - +method+: The HTTP method (GET, POST, PATCH, PUT, DELETE, HEAD, OPTIONS)
      #   as a symbol.
179
      # - +path+: The URI (as a String) on which you want to perform the
180 181 182 183 184 185 186 187 188 189 190 191
      #   request.
      # - +params+: 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 headers to pass, as a Hash. The headers will be
      #   merged into the Rack env hash.
      # - +env+: Additional env to pass, as a Hash. The headers will be
      #   merged into the Rack env hash.
      #
      # This method is rarely used directly. Use +#get+, +#post+, or other standard
192
      # HTTP methods in integration tests. +#process+ is only required when using a
193
      # request method that doesn't have a method defined in the integration tests.
194 195 196 197 198 199 200
      #
      # This method returns a Response object, which one can use to
      # inspect the details of the response. Furthermore, if this method was
      # called from an ActionDispatch::IntegrationTest object, then that
      # object's <tt>@response</tt> instance variable will point to the same
      # response object.
      #
201
      # Example:
202 203 204 205 206 207 208 209
      #   process :get, '/author', params: { since: 201501011400 }
      def process(method, path, params: nil, headers: nil, env: nil, xhr: false, as: nil)
        request_encoder = RequestEncoder.encoder(as)
        headers ||= {}

        if method == :get && as == :json && params
          headers["X-Http-Method-Override"] = "GET"
          method = :post
210
        end
211

212
        if path =~ %r{://}
213
          path = build_expanded_path(path) do |location|
214
            https! URI::HTTPS === location if location.scheme
215

216 217 218 219 220
            if url_host = location.host
              default = Rack::Request::DEFAULT_PORTS[location.scheme]
              url_host += ":#{location.port}" if default != location.port
              host! url_host
            end
221
          end
222
        end
223

224
        hostname, port = host.split(":")
225

226 227 228
        request_env = {
          :method => method,
          :params => request_encoder.encode_params(params),
229

230 231 232 233
          "SERVER_NAME"     => hostname,
          "SERVER_PORT"     => port || (https? ? "443" : "80"),
          "HTTPS"           => https? ? "on" : "off",
          "rack.url_scheme" => https? ? "https" : "http",
234

235 236 237 238
          "REQUEST_URI"    => path,
          "HTTP_HOST"      => host,
          "REMOTE_ADDR"    => remote_addr,
          "CONTENT_TYPE"   => request_encoder.content_type,
239
          "HTTP_ACCEPT"    => request_encoder.accept_header || accept
240
        }
241

242 243
        wrapped_headers = Http::Headers.from_hash({})
        wrapped_headers.merge!(headers) if headers
244

245 246 247 248
        if xhr
          wrapped_headers["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest"
          wrapped_headers["HTTP_ACCEPT"] ||= [Mime[:js], Mime[:html], Mime[:xml], "text/xml", "*/*"].join(", ")
        end
249

250 251 252 253 254 255 256
        # this modifies the passed request_env directly
        if wrapped_headers.present?
          Http::Headers.from_hash(request_env).merge!(wrapped_headers)
        end
        if env.present?
          Http::Headers.from_hash(request_env).merge!(env)
        end
257

258
        session = Rack::Test::Session.new(_mock_session)
259

260 261 262
        # NOTE: rack-test v0.5 doesn't build a default uri correctly
        # Make sure requested path is always a full uri
        session.request(build_full_uri(path, request_env), request_env)
263

264
        @request_count += 1
265
        @request = ActionDispatch::Request.new(session.last_request.env)
266 267 268 269 270
        response = _mock_session.last_response
        @response = ActionDispatch::TestResponse.from_response(response)
        @response.request = @request
        @html_document = nil
        @url_options = nil
271

272
        @controller = @request.controller_instance
273

274 275
        response.status
      end
276

277 278 279 280
      # Set the host name to use in the next request.
      #
      #   session.host! "www.example.com"
      alias :host! :host=
281

282 283 284
      private
        def _mock_session
          @_mock_session ||= Rack::MockSession.new(@app, host)
285
        end
286 287 288 289

        def build_full_uri(path, env)
          "#{env['rack.url_scheme']}://#{env['SERVER_NAME']}:#{env['SERVER_PORT']}#{path}"
        end
290

291
        def build_expanded_path(path)
292 293
          location = URI.parse(path)
          yield location if block_given?
294
          path = location.path
295 296
          location.query ? "#{path}?#{location.query}" : path
        end
297 298
    end

299
    module Runner
300 301
      include ActionDispatch::Assertions

302 303
      APP_SESSIONS = {}

304 305
      attr_reader :app

306 307 308 309 310
      def initialize(*args, &blk)
        super(*args, &blk)
        @integration_session = nil
      end

311
      def before_setup # :nodoc:
312
        @app = nil
E
eileencodes 已提交
313
        super
314 315 316
      end

      def integration_session
317
        @integration_session ||= create_session(app)
318 319
      end

320 321 322
      # Reset the current session. This is useful for testing multiple sessions
      # in a single test case.
      def reset!
323
        @integration_session = create_session(app)
324 325 326 327 328 329 330 331 332 333 334 335
      end

      def create_session(app)
        klass = APP_SESSIONS[app] ||= Class.new(Integration::Session) {
          # 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)
            include app.routes.url_helpers
            include app.routes.mounted_helpers
          end
        }
        klass.new(app)
336 337
      end

338 339 340 341
      def remove! # :nodoc:
        @integration_session = nil
      end

342
      %w(get post patch put head delete cookies assigns
343
         xml_http_request xhr get_via_redirect post_via_redirect).each do |method|
344
        define_method(method) do |*args|
345
          # reset the html_document variable, except for cookies/assigns calls
346
          unless method == "cookies" || method == "assigns"
347 348 349
            @html_document = nil
          end

350
          integration_session.__send__(method, *args).tap do
351 352 353 354 355 356 357 358 359 360 361 362 363 364 365
            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.
G
Guo Xiang Tan 已提交
366
      def open_session
367
        dup.tap do |session|
368
          session.reset!
369
          yield session if block_given?
370 371 372 373 374 375
        end
      end

      # Copy the instance variables from the current session instance into the
      # test instance.
      def copy_session_variables! #:nodoc:
A
Aaron Patterson 已提交
376 377 378
        @controller = @integration_session.controller
        @response   = @integration_session.response
        @request    = @integration_session.request
379 380
      end

381 382 383
      def default_url_options
        integration_session.default_url_options
      end
384

385 386
      def default_url_options=(options)
        integration_session.default_url_options = options
387 388
      end

389
      def respond_to_missing?(method, include_private = false)
390
        integration_session.respond_to?(method, include_private) || super
391 392
      end

393 394
      # Delegate unhandled messages to the current session instance.
      def method_missing(sym, *args, &block)
395 396
        if integration_session.respond_to?(sym)
          integration_session.__send__(sym, *args, &block).tap do
397 398 399 400
            copy_session_variables!
          end
        else
          super
401 402 403
        end
      end
    end
404 405
  end

406
  # An integration test spans multiple controllers and actions,
407 408 409 410
  # 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.
  #
411
  # At its simplest, you simply extend <tt>IntegrationTest</tt> and write your tests
412 413
  # using the get/post methods:
  #
414
  #   require "test_helper"
415
  #
416
  #   class ExampleTest < ActionDispatch::IntegrationTest
417 418 419 420 421 422 423 424
  #     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
425 426
  #       post "/login", params: { username: people(:jamis).username,
  #         password: people(:jamis).password }
427 428 429 430 431 432 433 434 435
  #       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
436
  # reference any named routes you happen to have defined.
437
  #
438
  #   require "test_helper"
439
  #
440
  #   class AdvancedTest < ActionDispatch::IntegrationTest
441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458
  #     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!
A
AvnerCohen 已提交
459
  #           get(room_url(id: room.id))
460 461 462 463 464
  #           assert(...)
  #           ...
  #         end
  #
  #         def speak(room, message)
465
  #           post "/say/#{room.id}", xhr: true, params: { message: message }
466 467 468 469 470 471 472 473 474
  #           assert(...)
  #           ...
  #         end
  #       end
  #
  #       def login(who)
  #         open_session do |sess|
  #           sess.extend(CustomAssertions)
  #           who = people(who)
475 476
  #           sess.post "/login", params: { username: who.username,
  #             password: who.password }
477 478 479 480
  #           assert(...)
  #         end
  #       end
  #   end
481 482 483 484 485 486 487 488 489 490 491 492 493 494
  #
  # Another longer example would be:
  #
  # A simple integration test that exercises multiple controllers:
  #
  #   require 'test_helper'
  #
  #   class UserFlowsTest < ActionDispatch::IntegrationTest
  #     test "login and browse site" do
  #       # login via https
  #       https!
  #       get "/login"
  #       assert_response :success
  #
495 496
  #       post "/login", params: { username: users(:david).username, password: users(:david).password }
  #       follow_redirect!
497 498 499 500 501 502
  #       assert_equal '/welcome', path
  #       assert_equal 'Welcome david!', flash[:notice]
  #
  #       https!(false)
  #       get "/articles/all"
  #       assert_response :success
503
  #       assert_select 'h1', 'Articles'
504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541
  #     end
  #   end
  #
  # As you can see the integration test involves multiple controllers and
  # exercises the entire stack from database to dispatcher. In addition you can
  # have multiple session instances open simultaneously in a test and extend
  # those instances with assertion methods to create a very powerful testing
  # DSL (domain-specific language) just for your application.
  #
  # Here's an example of multiple sessions and custom DSL in an integration test
  #
  #   require 'test_helper'
  #
  #   class UserFlowsTest < ActionDispatch::IntegrationTest
  #     test "login and browse site" do
  #       # User david logs in
  #       david = login(:david)
  #       # User guest logs in
  #       guest = login(:guest)
  #
  #       # Both are now available in different sessions
  #       assert_equal 'Welcome david!', david.flash[:notice]
  #       assert_equal 'Welcome guest!', guest.flash[:notice]
  #
  #       # User david can browse site
  #       david.browses_site
  #       # User guest can browse site as well
  #       guest.browses_site
  #
  #       # Continue with other assertions
  #     end
  #
  #     private
  #
  #       module CustomDsl
  #         def browses_site
  #           get "/products/all"
  #           assert_response :success
542
  #           assert_select 'h1', 'Products'
543 544 545 546 547 548 549 550
  #         end
  #       end
  #
  #       def login(user)
  #         open_session do |sess|
  #           sess.extend(CustomDsl)
  #           u = users(user)
  #           sess.https!
551
  #           sess.post "/login", params: { username: u.username, password: u.password }
552 553 554 555 556 557
  #           assert_equal '/welcome', sess.path
  #           sess.https!(false)
  #         end
  #       end
  #   end
  #
558 559 560
  # See the {request helpers documentation}[rdoc-ref:ActionDispatch::Integration::RequestHelpers] for help on how to
  # use +get+, etc.
  #
561 562
  # === Changing the request encoding
  #
563 564 565
  # You can also test your JSON API easily by setting what the request should
  # be encoded as:
  #
566
  #   require "test_helper"
567 568
  #
  #   class ApiTest < ActionDispatch::IntegrationTest
569
  #     test "creates articles" do
570
  #       assert_difference -> { Article.count } do
571
  #         post articles_path, params: { article: { title: "Ahoy!" } }, as: :json
572 573 574
  #       end
  #
  #       assert_response :success
O
Olivier 已提交
575
  #       assert_equal({ id: Article.last.id, title: "Ahoy!" }, response.parsed_body)
576 577 578
  #     end
  #   end
  #
579 580
  # The +as+ option passes an "application/json" Accept header (thereby setting
  # the request format to JSON unless overridden), sets the content type to
581
  # "application/json" and encodes the parameters as JSON.
582
  #
583 584
  # Calling +parsed_body+ on the response parses the response body based on the
  # last response MIME type.
585
  #
586 587
  # Out of the box, only <tt>:json</tt> is supported. But for any custom MIME
  # types you've registered, you can add your own encoders with:
588
  #
589 590 591 592
  #   ActionDispatch::IntegrationTest.register_encoder :wibble,
  #     param_encoder: -> params { params.to_wibble },
  #     response_parser: -> body { body }
  #
593 594 595
  # Where +param_encoder+ defines how the params should be encoded and
  # +response_parser+ defines how the response body should be parsed through
  # +parsed_body+.
596
  #
597 598
  # Consult the Rails Testing Guide for more.

599
  class IntegrationTest < ActiveSupport::TestCase
600
    include TestProcess::FixtureFile
601

602 603 604 605 606 607
    module UrlOptions
      extend ActiveSupport::Concern
      def url_options
        integration_session.url_options
      end
    end
608

609 610
    module Behavior
      extend ActiveSupport::Concern
611

612 613
      include Integration::Runner
      include ActionController::TemplateAssertions
614

615 616 617 618 619 620
      included do
        include ActionDispatch::Routing::UrlFor
        include UrlOptions # don't let UrlFor override the url_options method
        ActiveSupport.run_load_hooks(:action_dispatch_integration_test, self)
        @@app = nil
      end
621

622 623
      module ClassMethods
        def app
624 625 626 627 628
          if defined?(@@app) && @@app
            @@app
          else
            ActionDispatch.test_app
          end
629
        end
630

631 632 633
        def app=(app)
          @@app = app
        end
634

635
        def register_encoder(*args)
636
          RequestEncoder.register_encoder(*args)
637 638 639 640 641 642
        end
      end

      def app
        super || self.class.app
      end
643

644 645 646
      def document_root_element
        html_document.root
      end
647
    end
648

649
    include Behavior
650
  end
651
end