aboutsummaryrefslogtreecommitdiffstats
path: root/actionpack/lib/action_dispatch/testing
diff options
context:
space:
mode:
Diffstat (limited to 'actionpack/lib/action_dispatch/testing')
-rw-r--r--actionpack/lib/action_dispatch/testing/assertion_response.rb47
-rw-r--r--actionpack/lib/action_dispatch/testing/assertions.rb24
-rw-r--r--actionpack/lib/action_dispatch/testing/assertions/response.rb106
-rw-r--r--actionpack/lib/action_dispatch/testing/assertions/routing.rb227
-rw-r--r--actionpack/lib/action_dispatch/testing/integration.rb659
-rw-r--r--actionpack/lib/action_dispatch/testing/request_encoder.rb55
-rw-r--r--actionpack/lib/action_dispatch/testing/test_process.rb50
-rw-r--r--actionpack/lib/action_dispatch/testing/test_request.rb71
-rw-r--r--actionpack/lib/action_dispatch/testing/test_response.rb52
9 files changed, 1291 insertions, 0 deletions
diff --git a/actionpack/lib/action_dispatch/testing/assertion_response.rb b/actionpack/lib/action_dispatch/testing/assertion_response.rb
new file mode 100644
index 0000000000..dc019db6ac
--- /dev/null
+++ b/actionpack/lib/action_dispatch/testing/assertion_response.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module ActionDispatch
+ # This is a class that abstracts away an asserted response. It purposely
+ # does not inherit from Response because it doesn't need it. That means it
+ # does not have headers or a body.
+ class AssertionResponse
+ attr_reader :code, :name
+
+ GENERIC_RESPONSE_CODES = { # :nodoc:
+ success: "2XX",
+ missing: "404",
+ redirect: "3XX",
+ error: "5XX"
+ }
+
+ # Accepts a specific response status code as an Integer (404) or String
+ # ('404') or a response status range as a Symbol pseudo-code (:success,
+ # indicating any 200-299 status code).
+ def initialize(code_or_name)
+ if code_or_name.is_a?(Symbol)
+ @name = code_or_name
+ @code = code_from_name(code_or_name)
+ else
+ @name = name_from_code(code_or_name)
+ @code = code_or_name
+ end
+
+ raise ArgumentError, "Invalid response name: #{name}" if @code.nil?
+ raise ArgumentError, "Invalid response code: #{code}" if @name.nil?
+ end
+
+ def code_and_name
+ "#{code}: #{name}"
+ end
+
+ private
+
+ def code_from_name(name)
+ GENERIC_RESPONSE_CODES[name] || Rack::Utils::SYMBOL_TO_STATUS_CODE[name]
+ end
+
+ def name_from_code(code)
+ GENERIC_RESPONSE_CODES.invert[code] || Rack::Utils::HTTP_STATUS_CODES[code]
+ end
+ end
+end
diff --git a/actionpack/lib/action_dispatch/testing/assertions.rb b/actionpack/lib/action_dispatch/testing/assertions.rb
new file mode 100644
index 0000000000..08c2969685
--- /dev/null
+++ b/actionpack/lib/action_dispatch/testing/assertions.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require "rails-dom-testing"
+
+module ActionDispatch
+ module Assertions
+ autoload :ResponseAssertions, "action_dispatch/testing/assertions/response"
+ autoload :RoutingAssertions, "action_dispatch/testing/assertions/routing"
+
+ extend ActiveSupport::Concern
+
+ include ResponseAssertions
+ include RoutingAssertions
+ include Rails::Dom::Testing::Assertions
+
+ def html_document
+ @html_document ||= if @response.content_type.to_s.end_with?("xml")
+ Nokogiri::XML::Document.parse(@response.body)
+ else
+ Nokogiri::HTML::Document.parse(@response.body)
+ end
+ end
+ end
+end
diff --git a/actionpack/lib/action_dispatch/testing/assertions/response.rb b/actionpack/lib/action_dispatch/testing/assertions/response.rb
new file mode 100644
index 0000000000..8595ea03cf
--- /dev/null
+++ b/actionpack/lib/action_dispatch/testing/assertions/response.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+module ActionDispatch
+ module Assertions
+ # A small suite of assertions that test responses from \Rails applications.
+ module ResponseAssertions
+ RESPONSE_PREDICATES = { # :nodoc:
+ success: :successful?,
+ missing: :not_found?,
+ redirect: :redirection?,
+ error: :server_error?,
+ }
+
+ # Asserts that the response is one of the following types:
+ #
+ # * <tt>:success</tt> - Status code was in the 200-299 range
+ # * <tt>:redirect</tt> - Status code was in the 300-399 range
+ # * <tt>:missing</tt> - Status code was 404
+ # * <tt>:error</tt> - Status code was in the 500-599 range
+ #
+ # You can also pass an explicit status number like <tt>assert_response(501)</tt>
+ # or its symbolic equivalent <tt>assert_response(:not_implemented)</tt>.
+ # See Rack::Utils::SYMBOL_TO_STATUS_CODE for a full list.
+ #
+ # # Asserts that the response was a redirection
+ # assert_response :redirect
+ #
+ # # Asserts that the response code was status code 401 (unauthorized)
+ # assert_response 401
+ def assert_response(type, message = nil)
+ message ||= generate_response_message(type)
+
+ if RESPONSE_PREDICATES.keys.include?(type)
+ assert @response.send(RESPONSE_PREDICATES[type]), message
+ else
+ assert_equal AssertionResponse.new(type).code, @response.response_code, message
+ end
+ end
+
+ # Asserts that the redirection options passed in match those of the redirect called in the latest action.
+ # This match can be partial, such that <tt>assert_redirected_to(controller: "weblog")</tt> will also
+ # match the redirection of <tt>redirect_to(controller: "weblog", action: "show")</tt> and so on.
+ #
+ # # Asserts that the redirection was to the "index" action on the WeblogController
+ # assert_redirected_to controller: "weblog", action: "index"
+ #
+ # # Asserts that the redirection was to the named route login_url
+ # assert_redirected_to login_url
+ #
+ # # Asserts that the redirection was to the URL for @customer
+ # assert_redirected_to @customer
+ #
+ # # Asserts that the redirection matches the regular expression
+ # assert_redirected_to %r(\Ahttp://example.org)
+ def assert_redirected_to(options = {}, message = nil)
+ assert_response(:redirect, message)
+ return true if options === @response.location
+
+ redirect_is = normalize_argument_to_redirection(@response.location)
+ redirect_expected = normalize_argument_to_redirection(options)
+
+ message ||= "Expected response to be a redirect to <#{redirect_expected}> but was a redirect to <#{redirect_is}>"
+ assert_operator redirect_expected, :===, redirect_is, message
+ end
+
+ private
+ # Proxy to to_param if the object will respond to it.
+ def parameterize(value)
+ value.respond_to?(:to_param) ? value.to_param : value
+ end
+
+ def normalize_argument_to_redirection(fragment)
+ if Regexp === fragment
+ fragment
+ else
+ handle = @controller || ActionController::Redirecting
+ handle._compute_redirect_to_location(@request, fragment)
+ end
+ end
+
+ def generate_response_message(expected, actual = @response.response_code)
+ (+"Expected response to be a <#{code_with_name(expected)}>,"\
+ " but was a <#{code_with_name(actual)}>").concat(location_if_redirected).concat(response_body_if_short)
+ end
+
+ def response_body_if_short
+ return "" if @response.body.size > 500
+ "\nResponse body: #{@response.body}"
+ end
+
+ def location_if_redirected
+ return "" unless @response.redirection? && @response.location.present?
+ location = normalize_argument_to_redirection(@response.location)
+ " redirect to <#{location}>"
+ end
+
+ def code_with_name(code_or_name)
+ if RESPONSE_PREDICATES.values.include?("#{code_or_name}?".to_sym)
+ code_or_name = RESPONSE_PREDICATES.invert["#{code_or_name}?".to_sym]
+ end
+
+ AssertionResponse.new(code_or_name).code_and_name
+ end
+ end
+ end
+end
diff --git a/actionpack/lib/action_dispatch/testing/assertions/routing.rb b/actionpack/lib/action_dispatch/testing/assertions/routing.rb
new file mode 100644
index 0000000000..af41521c5c
--- /dev/null
+++ b/actionpack/lib/action_dispatch/testing/assertions/routing.rb
@@ -0,0 +1,227 @@
+# frozen_string_literal: true
+
+require "uri"
+require "active_support/core_ext/hash/indifferent_access"
+require "active_support/core_ext/string/access"
+require "action_controller/metal/exceptions"
+
+module ActionDispatch
+ module Assertions
+ # Suite of assertions to test routes generated by \Rails and the handling of requests made to them.
+ module RoutingAssertions
+ def setup # :nodoc:
+ @routes ||= nil
+ super
+ end
+
+ # Asserts that the routing of the given +path+ was handled correctly and that the parsed options (given in the +expected_options+ hash)
+ # match +path+. Basically, it asserts that \Rails recognizes the route given by +expected_options+.
+ #
+ # Pass a hash in the second argument (+path+) to specify the request method. This is useful for routes
+ # requiring a specific HTTP method. The hash should contain a :path with the incoming request path
+ # and a :method containing the required HTTP verb.
+ #
+ # # Asserts that POSTing to /items will call the create action on ItemsController
+ # assert_recognizes({controller: 'items', action: 'create'}, {path: 'items', method: :post})
+ #
+ # You can also pass in +extras+ with a hash containing URL parameters that would normally be in the query string. This can be used
+ # to assert that values in the query string will end up in the params hash correctly. To test query strings you must use the extras
+ # argument because appending the query string on the path directly will not work. For example:
+ #
+ # # Asserts that a path of '/items/list/1?view=print' returns the correct options
+ # assert_recognizes({controller: 'items', action: 'list', id: '1', view: 'print'}, 'items/list/1', { view: "print" })
+ #
+ # The +message+ parameter allows you to pass in an error message that is displayed upon failure.
+ #
+ # # Check the default route (i.e., the index action)
+ # assert_recognizes({controller: 'items', action: 'index'}, 'items')
+ #
+ # # Test a specific action
+ # assert_recognizes({controller: 'items', action: 'list'}, 'items/list')
+ #
+ # # Test an action with a parameter
+ # assert_recognizes({controller: 'items', action: 'destroy', id: '1'}, 'items/destroy/1')
+ #
+ # # Test a custom route
+ # assert_recognizes({controller: 'items', action: 'show', id: '1'}, 'view/item1')
+ def assert_recognizes(expected_options, path, extras = {}, msg = nil)
+ if path.is_a?(Hash) && path[:method].to_s == "all"
+ [:get, :post, :put, :delete].each do |method|
+ assert_recognizes(expected_options, path.merge(method: method), extras, msg)
+ end
+ else
+ request = recognized_request_for(path, extras, msg)
+
+ expected_options = expected_options.clone
+
+ expected_options.stringify_keys!
+
+ msg = message(msg, "") {
+ sprintf("The recognized options <%s> did not match <%s>, difference:",
+ request.path_parameters, expected_options)
+ }
+
+ assert_equal(expected_options, request.path_parameters, msg)
+ end
+ end
+
+ # Asserts that the provided options can be used to generate the provided path. This is the inverse of +assert_recognizes+.
+ # The +extras+ parameter is used to tell the request the names and values of additional request parameters that would be in
+ # a query string. The +message+ parameter allows you to specify a custom error message for assertion failures.
+ #
+ # The +defaults+ parameter is unused.
+ #
+ # # Asserts that the default action is generated for a route with no action
+ # assert_generates "/items", controller: "items", action: "index"
+ #
+ # # Tests that the list action is properly routed
+ # assert_generates "/items/list", controller: "items", action: "list"
+ #
+ # # Tests the generation of a route with a parameter
+ # assert_generates "/items/list/1", { controller: "items", action: "list", id: "1" }
+ #
+ # # Asserts that the generated route gives us our custom route
+ # assert_generates "changesets/12", { controller: 'scm', action: 'show_diff', revision: "12" }
+ def assert_generates(expected_path, options, defaults = {}, extras = {}, message = nil)
+ if %r{://}.match?(expected_path)
+ fail_on(URI::InvalidURIError, message) do
+ uri = URI.parse(expected_path)
+ expected_path = uri.path.to_s.empty? ? "/" : uri.path
+ end
+ else
+ expected_path = "/#{expected_path}" unless expected_path.first == "/"
+ end
+ # Load routes.rb if it hasn't been loaded.
+
+ options = options.clone
+ generated_path, query_string_keys = @routes.generate_extras(options, defaults)
+ found_extras = options.reject { |k, _| ! query_string_keys.include? k }
+
+ msg = message || sprintf("found extras <%s>, not <%s>", found_extras, extras)
+ assert_equal(extras, found_extras, msg)
+
+ msg = message || sprintf("The generated path <%s> did not match <%s>", generated_path,
+ expected_path)
+ assert_equal(expected_path, generated_path, msg)
+ end
+
+ # Asserts that path and options match both ways; in other words, it verifies that <tt>path</tt> generates
+ # <tt>options</tt> and then that <tt>options</tt> generates <tt>path</tt>. This essentially combines +assert_recognizes+
+ # and +assert_generates+ into one step.
+ #
+ # The +extras+ hash allows you to specify options that would normally be provided as a query string to the action. The
+ # +message+ parameter allows you to specify a custom error message to display upon failure.
+ #
+ # # Asserts a basic route: a controller with the default action (index)
+ # assert_routing '/home', controller: 'home', action: 'index'
+ #
+ # # Test a route generated with a specific controller, action, and parameter (id)
+ # assert_routing '/entries/show/23', controller: 'entries', action: 'show', id: 23
+ #
+ # # Asserts a basic route (controller + default action), with an error message if it fails
+ # assert_routing '/store', { controller: 'store', action: 'index' }, {}, {}, 'Route for store index not generated properly'
+ #
+ # # Tests a route, providing a defaults hash
+ # assert_routing 'controller/action/9', {id: "9", item: "square"}, {controller: "controller", action: "action"}, {}, {item: "square"}
+ #
+ # # Tests a route with an HTTP method
+ # assert_routing({ method: 'put', path: '/product/321' }, { controller: "product", action: "update", id: "321" })
+ def assert_routing(path, options, defaults = {}, extras = {}, message = nil)
+ assert_recognizes(options, path, extras, message)
+
+ controller, default_controller = options[:controller], defaults[:controller]
+ if controller && controller.include?(?/) && default_controller && default_controller.include?(?/)
+ options[:controller] = "/#{controller}"
+ end
+
+ generate_options = options.dup.delete_if { |k, _| defaults.key?(k) }
+ assert_generates(path.is_a?(Hash) ? path[:path] : path, generate_options, defaults, extras, message)
+ end
+
+ # A helper to make it easier to test different route configurations.
+ # This method temporarily replaces @routes with a new RouteSet instance.
+ #
+ # The new instance is yielded to the passed block. Typically the block
+ # will create some routes using <tt>set.draw { match ... }</tt>:
+ #
+ # with_routing do |set|
+ # set.draw do
+ # resources :users
+ # end
+ # assert_equal "/users", users_path
+ # end
+ #
+ def with_routing
+ old_routes, @routes = @routes, ActionDispatch::Routing::RouteSet.new
+ if defined?(@controller) && @controller
+ old_controller, @controller = @controller, @controller.clone
+ _routes = @routes
+
+ @controller.singleton_class.include(_routes.url_helpers)
+
+ if @controller.respond_to? :view_context_class
+ @controller.view_context_class = Class.new(@controller.view_context_class) do
+ include _routes.url_helpers
+ end
+ end
+ end
+ yield @routes
+ ensure
+ @routes = old_routes
+ if defined?(@controller) && @controller
+ @controller = old_controller
+ end
+ end
+
+ # ROUTES TODO: These assertions should really work in an integration context
+ def method_missing(selector, *args, &block)
+ if defined?(@controller) && @controller && defined?(@routes) && @routes && @routes.named_routes.route_defined?(selector)
+ @controller.send(selector, *args, &block)
+ else
+ super
+ end
+ end
+
+ private
+ # Recognizes the route for a given path.
+ def recognized_request_for(path, extras = {}, msg)
+ if path.is_a?(Hash)
+ method = path[:method]
+ path = path[:path]
+ else
+ method = :get
+ end
+
+ request = ActionController::TestRequest.create @controller.class
+
+ if %r{://}.match?(path)
+ fail_on(URI::InvalidURIError, msg) do
+ uri = URI.parse(path)
+ request.env["rack.url_scheme"] = uri.scheme || "http"
+ request.host = uri.host if uri.host
+ request.port = uri.port if uri.port
+ request.path = uri.path.to_s.empty? ? "/" : uri.path
+ end
+ else
+ path = "/#{path}" unless path.first == "/"
+ request.path = path
+ end
+
+ request.request_method = method if method
+
+ params = fail_on(ActionController::RoutingError, msg) do
+ @routes.recognize_path(path, method: method, extras: extras)
+ end
+ request.path_parameters = params.with_indifferent_access
+
+ request
+ end
+
+ def fail_on(exception_class, message)
+ yield
+ rescue exception_class => e
+ raise Minitest::Assertion, message || e.message
+ end
+ end
+ end
+end
diff --git a/actionpack/lib/action_dispatch/testing/integration.rb b/actionpack/lib/action_dispatch/testing/integration.rb
new file mode 100644
index 0000000000..45439a3bb1
--- /dev/null
+++ b/actionpack/lib/action_dispatch/testing/integration.rb
@@ -0,0 +1,659 @@
+# frozen_string_literal: true
+
+require "stringio"
+require "uri"
+require "active_support/core_ext/kernel/singleton_class"
+require "active_support/core_ext/object/try"
+require "rack/test"
+require "minitest"
+
+require "action_dispatch/testing/request_encoder"
+
+module ActionDispatch
+ module Integration #:nodoc:
+ module RequestHelpers
+ # Performs a GET request with the given parameters. See ActionDispatch::Integration::Session#process
+ # for more details.
+ def get(path, **args)
+ process(:get, path, **args)
+ end
+
+ # Performs a POST request with the given parameters. See ActionDispatch::Integration::Session#process
+ # for more details.
+ def post(path, **args)
+ process(:post, path, **args)
+ end
+
+ # Performs a PATCH request with the given parameters. See ActionDispatch::Integration::Session#process
+ # for more details.
+ def patch(path, **args)
+ process(:patch, path, **args)
+ end
+
+ # Performs a PUT request with the given parameters. See ActionDispatch::Integration::Session#process
+ # for more details.
+ def put(path, **args)
+ process(:put, path, **args)
+ end
+
+ # Performs a DELETE request with the given parameters. See ActionDispatch::Integration::Session#process
+ # for more details.
+ def delete(path, **args)
+ process(:delete, path, **args)
+ end
+
+ # Performs a HEAD request with the given parameters. See ActionDispatch::Integration::Session#process
+ # for more details.
+ def head(path, *args)
+ process(:head, path, *args)
+ 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. Any arguments are passed to the
+ # underlying call to `get`.
+ def follow_redirect!(**args)
+ raise "not a redirect! #{status} #{status_message}" unless redirect?
+ get(response.location, **args)
+ status
+ end
+ end
+
+ # An instance of this class represents a set of requests and responses
+ # performed sequentially by a test process. Because 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
+ DEFAULT_HOST = "www.example.com"
+
+ include Minitest::Assertions
+ include TestProcess, RequestHelpers, Assertions
+
+ %w( status status_message headers body redirect? ).each do |method|
+ delegate method, to: :response, allow_nil: true
+ end
+
+ %w( path ).each do |method|
+ delegate method, to: :request, allow_nil: true
+ end
+
+ # The hostname used in the last request.
+ def host
+ @host || DEFAULT_HOST
+ end
+ attr_writer :host
+
+ # The remote_addr used in the last request.
+ attr_accessor :remote_addr
+
+ # The Accept header to send.
+ attr_accessor :accept
+
+ # A map of the cookies returned by the last response, and which will be
+ # sent with the next request.
+ def cookies
+ _mock_session.cookie_jar
+ end
+
+ # 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
+
+ # A running counter of the number of requests processed.
+ attr_accessor :request_count
+
+ include ActionDispatch::Routing::UrlFor
+
+ # Create and initialize a new Session instance.
+ def initialize(app)
+ super()
+ @app = app
+
+ reset!
+ end
+
+ def url_options
+ @url_options ||= default_url_options.dup.tap do |url_options|
+ url_options.reverse_merge!(controller.url_options) if controller
+
+ if @app.respond_to?(:routes)
+ url_options.reverse_merge!(@app.routes.default_url_options)
+ end
+
+ url_options.reverse_merge!(host: host, protocol: https? ? "https" : "http")
+ end
+ 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!
+ @https = false
+ @controller = @request = @response = nil
+ @_mock_session = nil
+ @request_count = 0
+ @url_options = nil
+
+ self.host = DEFAULT_HOST
+ self.remote_addr = "127.0.0.1"
+ self.accept = "text/xml,application/xml,application/xhtml+xml," \
+ "text/html;q=0.9,text/plain;q=0.8,image/png," \
+ "*/*;q=0.5"
+
+ unless defined? @named_routes_configured
+ # the helpers are made protected by default--we make them public for
+ # easier access during testing and troubleshooting.
+ @named_routes_configured = true
+ end
+ end
+
+ # Specify whether or not the session should mimic a secure HTTPS request.
+ #
+ # session.https!
+ # session.https!(false)
+ def https!(flag = true)
+ @https = flag
+ end
+
+ # Returns +true+ if the session is mimicking a secure HTTPS request.
+ #
+ # if session.https?
+ # ...
+ # end
+ def https?
+ @https
+ end
+
+ # Performs the actual request.
+ #
+ # - +method+: The HTTP method (GET, POST, PATCH, PUT, DELETE, HEAD, OPTIONS)
+ # as a symbol.
+ # - +path+: The URI (as a String) on which you want to perform the
+ # 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.
+ # - +xhr+: Set to `true` if you want to make and Ajax request.
+ # Adds request headers characteristic of XMLHttpRequest e.g. HTTP_X_REQUESTED_WITH.
+ # The headers will be merged into the Rack env hash.
+ # - +as+: Used for encoding the request with different content type.
+ # Supports `:json` by default and will set the approriate request headers.
+ # The headers will be merged into the Rack env hash.
+ #
+ # This method is rarely used directly. Use +#get+, +#post+, or other standard
+ # HTTP methods in integration tests. +#process+ is only required when using a
+ # request method that doesn't have a method defined in the integration tests.
+ #
+ # This method returns the response status, after performing the request.
+ # Furthermore, if this method was called from an ActionDispatch::IntegrationTest object,
+ # then that object's <tt>@response</tt> instance variable will point to a Response object
+ # which one can use to inspect the details of the response.
+ #
+ # Example:
+ # 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
+ end
+
+ if %r{://}.match?(path)
+ path = build_expanded_path(path) do |location|
+ https! URI::HTTPS === location if location.scheme
+
+ if url_host = location.host
+ default = Rack::Request::DEFAULT_PORTS[location.scheme]
+ url_host += ":#{location.port}" if default != location.port
+ host! url_host
+ end
+ end
+ end
+
+ hostname, port = host.split(":")
+
+ request_env = {
+ :method => method,
+ :params => request_encoder.encode_params(params),
+
+ "SERVER_NAME" => hostname,
+ "SERVER_PORT" => port || (https? ? "443" : "80"),
+ "HTTPS" => https? ? "on" : "off",
+ "rack.url_scheme" => https? ? "https" : "http",
+
+ "REQUEST_URI" => path,
+ "HTTP_HOST" => host,
+ "REMOTE_ADDR" => remote_addr,
+ "CONTENT_TYPE" => request_encoder.content_type,
+ "HTTP_ACCEPT" => request_encoder.accept_header || accept
+ }
+
+ wrapped_headers = Http::Headers.from_hash({})
+ wrapped_headers.merge!(headers) if headers
+
+ if xhr
+ wrapped_headers["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest"
+ wrapped_headers["HTTP_ACCEPT"] ||= [Mime[:js], Mime[:html], Mime[:xml], "text/xml", "*/*"].join(", ")
+ end
+
+ # 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
+
+ session = Rack::Test::Session.new(_mock_session)
+
+ # 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)
+
+ @request_count += 1
+ @request = ActionDispatch::Request.new(session.last_request.env)
+ response = _mock_session.last_response
+ @response = ActionDispatch::TestResponse.from_response(response)
+ @response.request = @request
+ @html_document = nil
+ @url_options = nil
+
+ @controller = @request.controller_instance
+
+ response.status
+ end
+
+ # Set the host name to use in the next request.
+ #
+ # session.host! "www.example.com"
+ alias :host! :host=
+
+ private
+ def _mock_session
+ @_mock_session ||= Rack::MockSession.new(@app, host)
+ end
+
+ def build_full_uri(path, env)
+ "#{env['rack.url_scheme']}://#{env['SERVER_NAME']}:#{env['SERVER_PORT']}#{path}"
+ end
+
+ def build_expanded_path(path)
+ location = URI.parse(path)
+ yield location if block_given?
+ path = location.path
+ location.query ? "#{path}?#{location.query}" : path
+ end
+ end
+
+ module Runner
+ include ActionDispatch::Assertions
+
+ APP_SESSIONS = {}
+
+ attr_reader :app
+
+ def initialize(*args, &blk)
+ super(*args, &blk)
+ @integration_session = nil
+ end
+
+ def before_setup # :nodoc:
+ @app = nil
+ super
+ end
+
+ def integration_session
+ @integration_session ||= create_session(app)
+ end
+
+ # Reset the current session. This is useful for testing multiple sessions
+ # in a single test case.
+ def reset!
+ @integration_session = create_session(app)
+ 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)
+ end
+
+ def remove! # :nodoc:
+ @integration_session = nil
+ end
+
+ %w(get post patch put head delete cookies assigns follow_redirect!).each do |method|
+ define_method(method) do |*args|
+ # reset the html_document variable, except for cookies/assigns calls
+ unless method == "cookies" || method == "assigns"
+ @html_document = nil
+ end
+
+ integration_session.__send__(method, *args).tap do
+ 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.
+ def open_session
+ dup.tap do |session|
+ session.reset!
+ yield session if block_given?
+ end
+ end
+
+ # Copy the instance variables from the current session instance into the
+ # test instance.
+ def copy_session_variables! #:nodoc:
+ @controller = @integration_session.controller
+ @response = @integration_session.response
+ @request = @integration_session.request
+ end
+
+ def default_url_options
+ integration_session.default_url_options
+ end
+
+ def default_url_options=(options)
+ integration_session.default_url_options = options
+ end
+
+ private
+ def respond_to_missing?(method, _)
+ integration_session.respond_to?(method) || super
+ end
+
+ # Delegate unhandled messages to the current session instance.
+ def method_missing(method, *args, &block)
+ if integration_session.respond_to?(method)
+ integration_session.public_send(method, *args, &block).tap do
+ copy_session_variables!
+ end
+ else
+ super
+ end
+ end
+ end
+ end
+
+ # An integration test 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 <tt>IntegrationTest</tt> and write your tests
+ # using the get/post methods:
+ #
+ # require "test_helper"
+ #
+ # class ExampleTest < ActionDispatch::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", params: { 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 "test_helper"
+ #
+ # class AdvancedTest < ActionDispatch::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)
+ # post "/say/#{room.id}", xhr: true, params: { message: message }
+ # assert(...)
+ # ...
+ # end
+ # end
+ #
+ # def login(who)
+ # open_session do |sess|
+ # sess.extend(CustomAssertions)
+ # who = people(who)
+ # sess.post "/login", params: { username: who.username,
+ # password: who.password }
+ # assert(...)
+ # end
+ # end
+ # end
+ #
+ # 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
+ #
+ # post "/login", params: { username: users(:david).username, password: users(:david).password }
+ # follow_redirect!
+ # assert_equal '/welcome', path
+ # assert_equal 'Welcome david!', flash[:notice]
+ #
+ # https!(false)
+ # get "/articles/all"
+ # assert_response :success
+ # assert_select 'h1', 'Articles'
+ # 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
+ # assert_select 'h1', 'Products'
+ # end
+ # end
+ #
+ # def login(user)
+ # open_session do |sess|
+ # sess.extend(CustomDsl)
+ # u = users(user)
+ # sess.https!
+ # sess.post "/login", params: { username: u.username, password: u.password }
+ # assert_equal '/welcome', sess.path
+ # sess.https!(false)
+ # end
+ # end
+ # end
+ #
+ # See the {request helpers documentation}[rdoc-ref:ActionDispatch::Integration::RequestHelpers] for help on how to
+ # use +get+, etc.
+ #
+ # === Changing the request encoding
+ #
+ # You can also test your JSON API easily by setting what the request should
+ # be encoded as:
+ #
+ # require "test_helper"
+ #
+ # class ApiTest < ActionDispatch::IntegrationTest
+ # test "creates articles" do
+ # assert_difference -> { Article.count } do
+ # post articles_path, params: { article: { title: "Ahoy!" } }, as: :json
+ # end
+ #
+ # assert_response :success
+ # assert_equal({ id: Article.last.id, title: "Ahoy!" }, response.parsed_body)
+ # end
+ # end
+ #
+ # The +as+ option passes an "application/json" Accept header (thereby setting
+ # the request format to JSON unless overridden), sets the content type to
+ # "application/json" and encodes the parameters as JSON.
+ #
+ # Calling +parsed_body+ on the response parses the response body based on the
+ # last response MIME type.
+ #
+ # 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:
+ #
+ # ActionDispatch::IntegrationTest.register_encoder :wibble,
+ # param_encoder: -> params { params.to_wibble },
+ # response_parser: -> body { body }
+ #
+ # Where +param_encoder+ defines how the params should be encoded and
+ # +response_parser+ defines how the response body should be parsed through
+ # +parsed_body+.
+ #
+ # Consult the Rails Testing Guide for more.
+
+ class IntegrationTest < ActiveSupport::TestCase
+ include TestProcess::FixtureFile
+
+ module UrlOptions
+ extend ActiveSupport::Concern
+ def url_options
+ integration_session.url_options
+ end
+ end
+
+ module Behavior
+ extend ActiveSupport::Concern
+
+ include Integration::Runner
+ include ActionController::TemplateAssertions
+
+ 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
+
+ module ClassMethods
+ def app
+ if defined?(@@app) && @@app
+ @@app
+ else
+ ActionDispatch.test_app
+ end
+ end
+
+ def app=(app)
+ @@app = app
+ end
+
+ def register_encoder(*args)
+ RequestEncoder.register_encoder(*args)
+ end
+ end
+
+ def app
+ super || self.class.app
+ end
+
+ def document_root_element
+ html_document.root
+ end
+ end
+
+ include Behavior
+ end
+end
diff --git a/actionpack/lib/action_dispatch/testing/request_encoder.rb b/actionpack/lib/action_dispatch/testing/request_encoder.rb
new file mode 100644
index 0000000000..9889f61951
--- /dev/null
+++ b/actionpack/lib/action_dispatch/testing/request_encoder.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module ActionDispatch
+ class RequestEncoder # :nodoc:
+ class IdentityEncoder
+ def content_type; end
+ def accept_header; end
+ def encode_params(params); params; end
+ def response_parser; -> body { body }; end
+ end
+
+ @encoders = { identity: IdentityEncoder.new }
+
+ attr_reader :response_parser
+
+ def initialize(mime_name, param_encoder, response_parser)
+ @mime = Mime[mime_name]
+
+ unless @mime
+ raise ArgumentError, "Can't register a request encoder for " \
+ "unregistered MIME Type: #{mime_name}. See `Mime::Type.register`."
+ end
+
+ @response_parser = response_parser || -> body { body }
+ @param_encoder = param_encoder || :"to_#{@mime.symbol}".to_proc
+ end
+
+ def content_type
+ @mime.to_s
+ end
+
+ def accept_header
+ @mime.to_s
+ end
+
+ def encode_params(params)
+ @param_encoder.call(params) if params
+ end
+
+ def self.parser(content_type)
+ mime = Mime::Type.lookup(content_type)
+ encoder(mime ? mime.ref : nil).response_parser
+ end
+
+ def self.encoder(name)
+ @encoders[name] || @encoders[:identity]
+ end
+
+ def self.register_encoder(mime_name, param_encoder: nil, response_parser: nil)
+ @encoders[mime_name] = new(mime_name, param_encoder, response_parser)
+ end
+
+ register_encoder :json, response_parser: -> body { JSON.parse(body) }
+ end
+end
diff --git a/actionpack/lib/action_dispatch/testing/test_process.rb b/actionpack/lib/action_dispatch/testing/test_process.rb
new file mode 100644
index 0000000000..0b98f27f11
--- /dev/null
+++ b/actionpack/lib/action_dispatch/testing/test_process.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require "action_dispatch/middleware/cookies"
+require "action_dispatch/middleware/flash"
+
+module ActionDispatch
+ module TestProcess
+ module FixtureFile
+ # Shortcut for <tt>Rack::Test::UploadedFile.new(File.join(ActionDispatch::IntegrationTest.fixture_path, path), type)</tt>:
+ #
+ # post :change_avatar, params: { avatar: fixture_file_upload('files/spongebob.png', 'image/png') }
+ #
+ # To upload binary files on Windows, pass <tt>:binary</tt> as the last parameter.
+ # This will not affect other platforms:
+ #
+ # post :change_avatar, params: { avatar: fixture_file_upload('files/spongebob.png', 'image/png', :binary) }
+ def fixture_file_upload(path, mime_type = nil, binary = false)
+ if self.class.respond_to?(:fixture_path) && self.class.fixture_path &&
+ !File.exist?(path)
+ path = File.join(self.class.fixture_path, path)
+ end
+ Rack::Test::UploadedFile.new(path, mime_type, binary)
+ end
+ end
+
+ include FixtureFile
+
+ def assigns(key = nil)
+ raise NoMethodError,
+ "assigns has been extracted to a gem. To continue using it,
+ add `gem 'rails-controller-testing'` to your Gemfile."
+ end
+
+ def session
+ @request.session
+ end
+
+ def flash
+ @request.flash
+ end
+
+ def cookies
+ @cookie_jar ||= Cookies::CookieJar.build(@request, @request.cookies)
+ end
+
+ def redirect_to_url
+ @response.redirect_url
+ end
+ end
+end
diff --git a/actionpack/lib/action_dispatch/testing/test_request.rb b/actionpack/lib/action_dispatch/testing/test_request.rb
new file mode 100644
index 0000000000..6c5b7af50e
--- /dev/null
+++ b/actionpack/lib/action_dispatch/testing/test_request.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require "active_support/core_ext/hash/indifferent_access"
+require "rack/utils"
+
+module ActionDispatch
+ class TestRequest < Request
+ DEFAULT_ENV = Rack::MockRequest.env_for("/",
+ "HTTP_HOST" => "test.host",
+ "REMOTE_ADDR" => "0.0.0.0",
+ "HTTP_USER_AGENT" => "Rails Testing",
+ )
+
+ # Create a new test request with default +env+ values.
+ def self.create(env = {})
+ env = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application
+ env["rack.request.cookie_hash"] ||= {}.with_indifferent_access
+ new(default_env.merge(env))
+ end
+
+ def self.default_env
+ DEFAULT_ENV
+ end
+ private_class_method :default_env
+
+ def request_method=(method)
+ super(method.to_s.upcase)
+ end
+
+ def host=(host)
+ set_header("HTTP_HOST", host)
+ end
+
+ def port=(number)
+ set_header("SERVER_PORT", number.to_i)
+ end
+
+ def request_uri=(uri)
+ set_header("REQUEST_URI", uri)
+ end
+
+ def path=(path)
+ set_header("PATH_INFO", path)
+ end
+
+ def action=(action_name)
+ path_parameters[:action] = action_name.to_s
+ end
+
+ def if_modified_since=(last_modified)
+ set_header("HTTP_IF_MODIFIED_SINCE", last_modified)
+ end
+
+ def if_none_match=(etag)
+ set_header("HTTP_IF_NONE_MATCH", etag)
+ end
+
+ def remote_addr=(addr)
+ set_header("REMOTE_ADDR", addr)
+ end
+
+ def user_agent=(user_agent)
+ set_header("HTTP_USER_AGENT", user_agent)
+ end
+
+ def accept=(mime_types)
+ delete_header("action_dispatch.request.accepts")
+ set_header("HTTP_ACCEPT", Array(mime_types).collect(&:to_s).join(","))
+ end
+ end
+end
diff --git a/actionpack/lib/action_dispatch/testing/test_response.rb b/actionpack/lib/action_dispatch/testing/test_response.rb
new file mode 100644
index 0000000000..7c1202dc0e
--- /dev/null
+++ b/actionpack/lib/action_dispatch/testing/test_response.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require "action_dispatch/testing/request_encoder"
+
+module ActionDispatch
+ # Integration test methods such as ActionDispatch::Integration::Session#get
+ # and ActionDispatch::Integration::Session#post return objects of class
+ # TestResponse, which represent the HTTP response results of the requested
+ # controller actions.
+ #
+ # See Response for more information on controller response objects.
+ class TestResponse < Response
+ def self.from_response(response)
+ new response.status, response.headers, response.body
+ end
+
+ # Was the response successful?
+ def success?
+ ActiveSupport::Deprecation.warn(<<-MSG.squish)
+ The success? predicate is deprecated and will be removed in Rails 6.0.
+ Please use successful? as provided by Rack::Response::Helpers.
+ MSG
+ successful?
+ end
+
+ # Was the URL not found?
+ def missing?
+ ActiveSupport::Deprecation.warn(<<-MSG.squish)
+ The missing? predicate is deprecated and will be removed in Rails 6.0.
+ Please use not_found? as provided by Rack::Response::Helpers.
+ MSG
+ not_found?
+ end
+
+ # Was there a server-side error?
+ def error?
+ ActiveSupport::Deprecation.warn(<<-MSG.squish)
+ The error? predicate is deprecated and will be removed in Rails 6.0.
+ Please use server_error? as provided by Rack::Response::Helpers.
+ MSG
+ server_error?
+ end
+
+ def parsed_body
+ @parsed_body ||= response_parser.call(body)
+ end
+
+ def response_parser
+ @response_parser ||= RequestEncoder.parser(content_type)
+ end
+ end
+end