1 require 'active_support/test_case'
2 require 'action_controller/dispatcher'
3 require 'action_controller/test_process'
8 module ActionController
9 module Integration
#:nodoc:
10 # An integration Session instance represents a set of requests and responses
11 # performed sequentially by some virtual user. Becase you can instantiate
12 # multiple sessions and run them side-by-side, you can also mimic (to some
13 # limited extent) multiple simultaneous users interacting with your system.
15 # Typically, you will instantiate a new session using IntegrationTest#open_session,
16 # rather than instantiating Integration::Session directly.
18 include Test
::Unit::Assertions
19 include ActionController
::Assertions
20 include ActionController
::TestProcess
22 # The integer HTTP status code of the last request.
25 # The status message that accompanied the status code of the last request.
26 attr_reader
:status_message
28 # The URI of the last request.
31 # The hostname used in the last request.
34 # The remote_addr used in the last request.
35 attr_accessor
:remote_addr
37 # The Accept header to send.
40 # A map of the cookies returned by the last response, and which will be
41 # sent with the next request.
44 # A map of the headers returned by the last response.
47 # A reference to the controller instance used by the last request.
48 attr_reader
:controller
50 # A reference to the request instance used by the last request.
53 # A reference to the response instance used by the last request.
56 # A running counter of the number of requests processed.
57 attr_accessor
:request_count
59 class MultiPartNeededException
< Exception
62 # Create and initialize a new Session instance.
67 # Resets the instance. This can be used to reset the state information
68 # in an existing session instance, so it can be used from a clean-slate
73 @status = @path = @headers = nil
74 @result = @status_message = nil
77 @controller = @request = @response = nil
80 self.host
= "www.example.com"
81 self.remote_addr
= "127.0.0.1"
82 self.accept
= "text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5"
84 unless defined? @named_routes_configured
85 # install the named routes in this session instance.
86 klass
= class<<self; self; end
87 Routing
::Routes.install_helpers(klass
)
89 # the helpers are made protected by default--we make them public for
90 # easier access during testing and troubleshooting.
91 klass
.module_eval
{ public
*Routing
::Routes.named_routes
.helpers
}
92 @named_routes_configured = true
96 # Specify whether or not the session should mimic a secure HTTPS request.
99 # session.https!(false)
100 def https
!(flag
=true)
104 # Return +true+ if the session is mimicking a secure HTTPS request.
113 # Set the host name to use in the next request.
115 # session.host! "www.example.com"
120 # Follow a single redirect response. If the last response was not a
121 # redirect, an exception will be raised. Otherwise, the redirect is
122 # performed on the location header.
124 raise "not a redirect! #{@status} #{@status_message}" unless redirect
?
125 get(interpret_uri(headers
['location'].first
))
129 # Performs a request using the specified method, following any subsequent
130 # redirect. Note that the redirects are followed until the response is
131 # not a redirect--this means you may run into an infinite loop if your
132 # redirect loops back to itself.
133 def request_via_redirect(http_method
, path
, parameters
= nil, headers
= nil)
134 send(http_method
, path
, parameters
, headers
)
135 follow_redirect
! while redirect
?
139 # Performs a GET request, following any subsequent redirect.
140 # See +request_via_redirect+ for more information.
141 def get_via_redirect(path
, parameters
= nil, headers
= nil)
142 request_via_redirect(:get, path
, parameters
, headers
)
145 # Performs a POST request, following any subsequent redirect.
146 # See +request_via_redirect+ for more information.
147 def post_via_redirect(path
, parameters
= nil, headers
= nil)
148 request_via_redirect(:post, path
, parameters
, headers
)
151 # Performs a PUT request, following any subsequent redirect.
152 # See +request_via_redirect+ for more information.
153 def put_via_redirect(path
, parameters
= nil, headers
= nil)
154 request_via_redirect(:put, path
, parameters
, headers
)
157 # Performs a DELETE request, following any subsequent redirect.
158 # See +request_via_redirect+ for more information.
159 def delete_via_redirect(path
, parameters
= nil, headers
= nil)
160 request_via_redirect(:delete, path
, parameters
, headers
)
163 # Returns +true+ if the last response was a redirect.
168 # Performs a GET request with the given parameters.
170 # - +path+: The URI (as a String) on which you want to perform a GET request.
171 # - +parameters+: The HTTP parameters that you want to pass. This may be +nil+,
172 # a Hash, or a String that is appropriately encoded
173 # (<tt>application/x-www-form-urlencoded</tt> or <tt>multipart/form-data</tt>).
174 # - +headers+: Additional HTTP headers to pass, as a Hash. The keys will
175 # automatically be upcased, with the prefix 'HTTP_' added if needed.
177 # This method returns an AbstractResponse object, which one can use to inspect
178 # the details of the response. Furthermore, if this method was called from an
179 # ActionController::IntegrationTest object, then that object's <tt>@response</tt>
180 # instance variable will point to the same response object.
182 # You can also perform POST, PUT, DELETE, and HEAD requests with +post+,
183 # +put+, +delete+, and +head+.
184 def get(path
, parameters
= nil, headers
= nil)
185 process
:get, path
, parameters
, headers
188 # Performs a POST request with the given parameters. See get() for more details.
189 def post(path
, parameters
= nil, headers
= nil)
190 process
:post, path
, parameters
, headers
193 # Performs a PUT request with the given parameters. See get() for more details.
194 def put(path
, parameters
= nil, headers
= nil)
195 process
:put, path
, parameters
, headers
198 # Performs a DELETE request with the given parameters. See get() for more details.
199 def delete(path
, parameters
= nil, headers
= nil)
200 process
:delete, path
, parameters
, headers
203 # Performs a HEAD request with the given parameters. See get() for more details.
204 def head(path
, parameters
= nil, headers
= nil)
205 process
:head, path
, parameters
, headers
208 # Performs an XMLHttpRequest request with the given parameters, mirroring
209 # a request from the Prototype library.
211 # The request_method is :get, :post, :put, :delete or :head; the
212 # parameters are +nil+, a hash, or a url-encoded or multipart string;
213 # the headers are a hash. Keys are automatically upcased and prefixed
214 # with 'HTTP_' if not already.
215 def xml_http_request(request_method
, path
, parameters
= nil, headers
= nil)
217 headers
['X-Requested-With'] = 'XMLHttpRequest'
218 headers
['Accept'] ||= 'text/javascript, text/html, application/xml, text/xml, */*'
220 process(request_method
, path
, parameters
, headers
)
222 alias xhr
:xml_http_request
224 # Returns the URL for the given options, according to the rules specified
225 # in the application's routes.
227 controller
? controller
.url_for(options
) : generic_url_rewriter
.rewrite(options
)
231 # Tailors the session based on the given URI, setting the HTTPS value
233 def interpret_uri(path
)
234 location
= URI
.parse(path
)
235 https
! URI
::HTTPS === location
if location
.scheme
236 host
! location
.host
if location
.host
237 location
.query
? "#{location.path}?#{location.query}" : location
.path
240 # Performs the actual request.
241 def process(method
, path
, parameters
= nil, headers
= nil)
242 data = requestify(parameters
)
243 path
= interpret_uri(path
) if path
=~
%r
{://}
244 path
= "/#{path}" unless path
[0] == ?/
249 env["QUERY_STRING"] = data
254 "REQUEST_METHOD" => method
.to_s
.upcase
,
255 "REQUEST_URI" => path
,
257 "REMOTE_ADDR" => remote_addr
,
258 "SERVER_PORT" => (https
? ? "443" : "80"),
259 "CONTENT_TYPE" => "application/x-www-form-urlencoded",
260 "CONTENT_LENGTH" => data ? data.length
.to_s
: nil,
261 "HTTP_COOKIE" => encode_cookies
,
262 "HTTPS" => https
? ? "on" : "off",
263 "HTTP_ACCEPT" => accept
266 (headers
|| {}).each
do |key
, value
|
267 key
= key
.to_s
.upcase
.gsub(/-/, "_")
268 key
= "HTTP_#{key}" unless env.has_key
?(key
) || key
=~
/^HTTP_/
272 unless ActionController
::Base.respond_to
?(:clear_last_instantiation!)
273 ActionController
::Base.module_eval
{ include ControllerCapture
}
276 ActionController
::Base.clear_last_instantiation
!
278 env['rack.input'] = data.is_a
?(IO
) ? data : StringIO
.new(data || '')
279 @status, @headers, result_body
= ActionController
::Dispatcher.new
.mark_as_test_request
!.call(env)
282 @controller = ActionController
::Base.last_instantiation
283 @request = @controller.request
284 @response = @controller.response
286 # Decorate the response with the standard behavior of the TestResponse
287 # so that things like assert_response can be used in integration
289 @response.extend(TestResponseBehavior
)
293 # Inject status back in for backwords compatibility with CGI
294 @headers['Status'] = @status
296 @status, @status_message = @status.split(/ /)
297 @status = @status.to_i
299 cgi_headers
= Hash
.new
{ |h
,k
| h
[k
] = [] }
300 @headers.each
do |key
, value
|
301 cgi_headers
[key
.downcase
] << value
303 cgi_headers
['set-cookie'] = cgi_headers
['set-cookie'].first
304 @headers = cgi_headers
306 @response.headers
['cookie'] ||= []
307 (@headers['set-cookie'] || []).each
do |cookie
|
308 name
, value
= cookie
.match(/^([^=]*)=([^;]*);/)[1,2]
309 @cookies[name
] = value
311 # Fake CGI cookie header
312 # DEPRECATE: Use response.headers["Set-Cookie"] instead
313 @response.headers
['cookie'] << CGI
::Cookie::new("name" => name
, "value" => value
)
317 rescue MultiPartNeededException
318 boundary
= "----------XnJLe9ZIbbGUYtzPQJ16u1"
319 status
= process(method
, path
, multipart_body(parameters
, boundary
), (headers
|| {}).merge({"CONTENT_TYPE" => "multipart/form-data; boundary=#{boundary}"}))
323 # Encode the cookies hash in a format suitable for passing to a
326 cookies
.inject("") do |string
, (name
, value
)|
327 string
<< "#{name}=#{value}; "
331 # Get a temporary URL writer object
332 def generic_url_rewriter
334 'REQUEST_METHOD' => "GET",
335 'QUERY_STRING' => "",
336 "REQUEST_URI" => "/",
338 "SERVER_PORT" => https
? ? "443" : "80",
339 "HTTPS" => https
? ? "on" : "off"
341 ActionController
::UrlRewriter.new(ActionController
::RackRequest.new(env), {})
344 def name_with_prefix(prefix
, name
)
345 prefix
? "#{prefix}[#{name}]" : name
.to_s
348 # Convert the given parameters to a request string. The parameters may
349 # be a string, +nil+, or a Hash.
350 def requestify(parameters
, prefix
=nil)
351 if TestUploadedFile
=== parameters
352 raise MultiPartNeededException
353 elsif Hash
=== parameters
354 return nil if parameters
.empty
?
355 parameters
.map
{ |k
,v
| requestify(v
, name_with_prefix(prefix
, k
)) }.join("&")
356 elsif Array
=== parameters
357 parameters
.map
{ |v
| requestify(v
, name_with_prefix(prefix
, "")) }.join("&")
361 "#{CGI.escape(prefix)}=#{CGI.escape(parameters.to_s)}"
365 def multipart_requestify(params
, first
=true)
366 returning Hash
.new
do |p
|
367 params
.each
do |key
, value
|
368 k
= first
? CGI
.escape(key
.to_s
) : "[#{CGI.escape(key.to_s)}]"
370 multipart_requestify(value
, false).each
do |subkey
, subvalue
|
371 p
[k
+ subkey
] = subvalue
380 def multipart_body(params
, boundary
)
381 multipart_requestify(params
).map
do |key
, value
|
382 if value
.respond_to
?(:original_filename)
383 File
.open(value
.path
) do |f
|
384 f
.set_encoding(Encoding
::BINARY) if f
.respond_to
?(:set_encoding)
388 Content-Disposition: form-data; name="#{key}"; filename="#{CGI.escape(value.original_filename)}"\r
389 Content-Type: #{value.content_type}\r
390 Content-Length: #{File.stat(value.path).size}\r
398 Content-Disposition: form-data; name="#{key}"\r
403 end.join("")+"--#{boundary}--\r"
407 # A module used to extend ActionController::Base, so that integration tests
408 # can capture the controller used to satisfy a request.
409 module ControllerCapture
#:nodoc:
410 def self.included(base
)
411 base
.extend(ClassMethods
)
414 alias_method_chain
:new, :capture
419 module ClassMethods
#:nodoc:
420 mattr_accessor
:last_instantiation
422 def clear_last_instantiation
!
423 self.last_instantiation
= nil
426 def new_with_capture(*args
)
427 controller
= new_without_capture(*args
)
428 self.last_instantiation
||= controller
435 # Reset the current session. This is useful for testing multiple sessions
436 # in a single test case.
438 @integration_session = open_session
441 %w(get post put head delete cookies assigns
442 xml_http_request xhr get_via_redirect post_via_redirect
).each
do |method
|
443 define_method(method
) do |*args
|
444 reset
! unless @integration_session
445 # reset the html_document variable, but only for new get/post calls
446 @html_document = nil unless %w(cookies assigns
).include?(method
)
447 returning
@integration_session.__send__(method
, *args
) do
448 copy_session_variables
!
453 # Open a new session instance. If a block is given, the new session is
454 # yielded to the block before being returned.
456 # session = open_session do |sess|
457 # sess.extend(CustomAssertions)
460 # By default, a single session is automatically created for you, but you
461 # can use this method to open multiple sessions that ought to be tested
464 session
= Integration
::Session.new
466 # delegate the fixture accessors back to the test instance
467 extras
= Module
.new
{ attr_accessor
:delegate, :test_result }
468 if self.class.respond_to
?(:fixture_table_names)
469 self.class.fixture_table_names
.each
do |table_name
|
470 name
= table_name
.tr(".", "_")
471 next unless respond_to
?(name
)
472 extras
.__send__(:define_method, name
) { |*args
| delegate
.send(name
, *args
) }
476 # delegate add_assertion to the test case
477 extras
.__send__(:define_method, :add_assertion) { test_result
.add_assertion
}
478 session
.extend(extras
)
479 session
.delegate
= self
480 session
.test_result
= @_result
482 yield session
if block_given
?
486 # Copy the instance variables from the current session instance into the
488 def copy_session_variables
! #:nodoc:
489 return unless @integration_session
490 %w(controller response request
).each
do |var
|
491 instance_variable_set("@#{var}", @integration_session.__send__(var
))
495 # Delegate unhandled messages to the current session instance.
496 def method_missing(sym
, *args
, &block
)
497 reset
! unless @integration_session
498 returning
@integration_session.__send__(sym
, *args
, &block
) do
499 copy_session_variables
!
505 # An IntegrationTest is one that spans multiple controllers and actions,
506 # tying them all together to ensure they work together as expected. It tests
507 # more completely than either unit or functional tests do, exercising the
508 # entire stack, from the dispatcher to the database.
510 # At its simplest, you simply extend IntegrationTest and write your tests
511 # using the get/post methods:
513 # require "#{File.dirname(__FILE__)}/test_helper"
515 # class ExampleTest < ActionController::IntegrationTest
519 # # get the login page
521 # assert_equal 200, status
523 # # post the login and follow through to the home page
524 # post "/login", :username => people(:jamis).username,
525 # :password => people(:jamis).password
527 # assert_equal 200, status
528 # assert_equal "/home", path
532 # However, you can also have multiple session instances open per test, and
533 # even extend those instances with assertions and methods to create a very
534 # powerful testing DSL that is specific for your application. You can even
535 # reference any named routes you happen to have defined!
537 # require "#{File.dirname(__FILE__)}/test_helper"
539 # class AdvancedTest < ActionController::IntegrationTest
540 # fixtures :people, :rooms
542 # def test_login_and_speak
543 # jamis, david = login(:jamis), login(:david)
544 # room = rooms(:office)
547 # jamis.speak(room, "anybody home?")
550 # david.speak(room, "hello!")
555 # module CustomAssertions
557 # # reference a named route, for maximum internal consistency!
558 # get(room_url(:id => room.id))
563 # def speak(room, message)
564 # xml_http_request "/say/#{room.id}", :message => message
571 # open_session do |sess|
572 # sess.extend(CustomAssertions)
574 # sess.post "/login", :username => who.username,
575 # :password => who.password
580 class IntegrationTest
< ActiveSupport
::TestCase
581 include Integration
::Runner
583 # Work around a bug in test/unit caused by the default test being named
584 # as a symbol (:default_test), which causes regex test filters
585 # (like "ruby test.rb -n /foo/") to fail because =~ doesn't work on
587 def initialize(name
) #:nodoc:
591 # Work around test/unit's requirement that every subclass of TestCase have
592 # at least one test method. Note that this implementation extends to all
593 # subclasses, as well, so subclasses of IntegrationTest may also exist
594 # without any test methods.
595 def run(*args
) #:nodoc:
596 return if @method_name == "default_test"
600 # Because of how use_instantiated_fixtures and use_transactional_fixtures
601 # are defined, we need to treat them as special cases. Otherwise, users
602 # would potentially have to set their values for both Test::Unit::TestCase
603 # ActionController::IntegrationTest, since by the time the value is set on
604 # TestCase, IntegrationTest has already been defined and cannot inherit
605 # changes to those variables. So, we make those two attributes copy-on-write.
608 def use_transactional_fixtures
=(flag
) #:nodoc:
609 @_use_transactional_fixtures = true
610 @use_transactional_fixtures = flag
613 def use_instantiated_fixtures
=(flag
) #:nodoc:
614 @_use_instantiated_fixtures = true
615 @use_instantiated_fixtures = flag
618 def use_transactional_fixtures
#:nodoc:
619 @_use_transactional_fixtures ?
620 @use_transactional_fixtures :
621 superclass
.use_transactional_fixtures
624 def use_instantiated_fixtures
#:nodoc:
625 @_use_instantiated_fixtures ?
626 @use_instantiated_fixtures :
627 superclass
.use_instantiated_fixtures