Ruby on Rails | Screencasts | Download | Documentation | Weblog | Community | Source

root/branches/1-2-stable/actionpack/lib/action_controller/integration.rb

Revision 7319, 19.5 kB (checked in by nzkoz, 1 year ago)

Integration tests: introduce methods for other HTTP methods. Closes #6353. Merges [6203]

Line 
1 require 'dispatcher'
2 require 'stringio'
3 require 'uri'
4 require 'action_controller/test_process'
5
6 module ActionController
7   module Integration #:nodoc:
8     # An integration Session instance represents a set of requests and responses
9     # performed sequentially by some virtual user. Becase you can instantiate
10     # multiple sessions and run them side-by-side, you can also mimic (to some
11     # limited extent) multiple simultaneous users interacting with your system.
12     #
13     # Typically, you will instantiate a new session using IntegrationTest#open_session,
14     # rather than instantiating Integration::Session directly.
15     class Session
16       include Test::Unit::Assertions
17       include ActionController::Assertions
18       include ActionController::TestProcess
19
20       # The integer HTTP status code of the last request.
21       attr_reader :status
22
23       # The status message that accompanied the status code of the last request.
24       attr_reader :status_message
25
26       # The URI of the last request.
27       attr_reader :path
28
29       # The hostname used in the last request.
30       attr_accessor :host
31
32       # The remote_addr used in the last request.
33       attr_accessor :remote_addr
34
35       # The Accept header to send.
36       attr_accessor :accept
37
38       # A map of the cookies returned by the last response, and which will be
39       # sent with the next request.
40       attr_reader :cookies
41
42       # A map of the headers returned by the last response.
43       attr_reader :headers
44
45       # A reference to the controller instance used by the last request.
46       attr_reader :controller
47
48       # A reference to the request instance used by the last request.
49       attr_reader :request
50
51       # A reference to the response instance used by the last request.
52       attr_reader :response
53
54       # Create an initialize a new Session instance.
55       def initialize
56         reset!
57       end
58
59       # Resets the instance. This can be used to reset the state information
60       # in an existing session instance, so it can be used from a clean-slate
61       # condition.
62       #
63       #   session.reset!
64       def reset!
65         @status = @path = @headers = nil
66         @result = @status_message = nil
67         @https = false
68         @cookies = {}
69         @controller = @request = @response = nil
70
71         self.host        = "www.example.com"
72         self.remote_addr = "127.0.0.1"
73         self.accept      = "text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5"
74
75         unless @named_routes_configured
76           # install the named routes in this session instance.
77           klass = class<<self; self; end
78           Routing::Routes.named_routes.install(klass)
79
80           # the helpers are made protected by default--we make them public for
81           # easier access during testing and troubleshooting.
82           klass.send(:public, *Routing::Routes.named_routes.helpers)
83           @named_routes_configured = true
84         end
85       end
86
87       # Specify whether or not the session should mimic a secure HTTPS request.
88       #
89       #   session.https!
90       #   session.https!(false)
91       def https!(flag=true)
92         @https = flag
93       end
94
95       # Return +true+ if the session is mimicing a secure HTTPS request.
96       #
97       #   if session.https?
98       #     ...
99       #   end
100       def https?
101         @https
102       end
103
104       # Set the host name to use in the next request.
105       #
106       #   session.host! "www.example.com"
107       def host!(name)
108         @host = name
109       end
110
111       # Follow a single redirect response. If the last response was not a
112       # redirect, an exception will be raised. Otherwise, the redirect is
113       # performed on the location header.
114       def follow_redirect!
115         raise "not a redirect! #{@status} #{@status_message}" unless redirect?
116         get(interpret_uri(headers['location'].first))
117         status
118       end
119
120       # Performs a GET request, following any subsequent redirect. Note that
121       # the redirects are followed until the response is not a redirect--this
122       # means you may run into an infinite loop if your redirect loops back to
123       # itself.
124       def get_via_redirect(path, args={})
125         get path, args
126         follow_redirect! while redirect?
127         status
128       end
129
130       # Performs a POST request, following any subsequent redirect. This is
131       # vulnerable to infinite loops, the same as #get_via_redirect.
132       def post_via_redirect(path, args={})
133         post path, args
134         follow_redirect! while redirect?
135         status
136       end
137
138       # Returns +true+ if the last response was a redirect.
139       def redirect?
140         status/100 == 3
141       end
142
143       # Performs a GET request with the given parameters. The parameters may
144       # be +nil+, a Hash, or a string that is appropriately encoded
145       # (application/x-www-form-urlencoded or multipart/form-data).  The headers
146       # should be a hash.  The keys will automatically be upcased, with the
147       # prefix 'HTTP_' added if needed.
148       #
149       # You can also perform POST, PUT, DELETE, and HEAD requests with #post,
150       # #put, #delete, and #head.
151       def get(path, parameters=nil, headers=nil)
152         process :get, path, parameters, headers
153       end
154
155       # Performs a POST request with the given parameters. See get() for more details.
156       def post(path, parameters=nil, headers=nil)
157         process :post, path, parameters, headers
158       end
159
160       # Performs a PUT request with the given parameters. See get() for more details.
161       def put(path, parameters=nil, headers=nil)
162         process :put, path, parameters, headers
163       end
164
165       # Performs a DELETE request with the given parameters. See get() for more details.
166       def delete(path, parameters=nil, headers=nil)
167         process :delete, path, parameters, headers
168       end
169
170       # Performs a HEAD request with the given parameters. See get() for more details.
171       def head(path, parameters=nil, headers=nil)
172         process :head, path, parameters, headers
173       end
174
175       # Performs an XMLHttpRequest request with the given parameters, mirroring
176       # a request from the Prototype library.
177       #
178       # The request_method is :get, :post, :put, :delete or :head; the
179       # parameters are +nil+, a hash, or a url-encoded or multipart string;
180       # the headers are a hash.  Keys are automatically upcased and prefixed
181       # with 'HTTP_' if not already.
182       #
183       # This method used to omit the request_method parameter, assuming it
184       # was :post. This was deprecated in Rails 1.2.4. Always pass the request
185       # method as the first argument.
186       def xml_http_request(request_method, path, parameters = nil, headers = nil)
187         unless request_method.is_a?(Symbol)
188           ActiveSupport::Deprecation.warn 'xml_http_request now takes the request_method (:get, :post, etc.) as the first argument. It used to assume :post, so add the :post argument to your existing method calls to silence this warning.'
189           request_method, path, parameters, headers = :post, request_method, path, parameters
190         end
191
192         headers ||= {}
193         headers['X-Requested-With'] = 'XMLHttpRequest'
194         headers['Accept'] = 'text/javascript, text/html, application/xml, text/xml, */*'
195
196         process(request_method, path, parameters, headers)
197       end
198       alias xhr :xml_http_request
199
200       # Returns the URL for the given options, according to the rules specified
201       # in the application's routes.
202       def url_for(options)
203         controller ? controller.url_for(options) : generic_url_rewriter.rewrite(options)
204       end
205
206       private
207         class MockCGI < CGI #:nodoc:
208           attr_accessor :stdinput, :stdoutput, :env_table
209
210           def initialize(env, input=nil)
211             self.env_table = env
212             self.stdinput = StringIO.new(input || "")
213             self.stdoutput = StringIO.new
214
215             super()
216           end
217         end
218
219         # Tailors the session based on the given URI, setting the HTTPS value
220         # and the hostname.
221         def interpret_uri(path)
222           location = URI.parse(path)
223           https! URI::HTTPS === location if location.scheme
224           host! location.host if location.host
225           location.query ? "#{location.path}?#{location.query}" : location.path
226         end
227
228         # Performs the actual request.
229         def process(method, path, parameters=nil, headers=nil)
230           data = requestify(parameters)
231           path = interpret_uri(path) if path =~ %r{://}
232           path = "/#{path}" unless path[0] == ?/
233           @path = path
234           env = {}
235
236           if method == :get
237             env["QUERY_STRING"] = data
238             data = nil
239           end
240
241           env.update(
242             "REQUEST_METHOD" => method.to_s.upcase,
243             "REQUEST_URI"    => path,
244             "HTTP_HOST"      => host,
245             "REMOTE_ADDR"    => remote_addr,
246             "SERVER_PORT"    => (https? ? "443" : "80"),
247             "CONTENT_TYPE"   => "application/x-www-form-urlencoded",
248             "CONTENT_LENGTH" => data ? data.length.to_s : nil,
249             "HTTP_COOKIE"    => encode_cookies,
250             "HTTPS"          => https? ? "on" : "off",
251             "HTTP_ACCEPT"    => accept
252           )
253
254           (headers || {}).each do |key, value|
255             key = key.to_s.upcase.gsub(/-/, "_")
256             key = "HTTP_#{key}" unless env.has_key?(key) || key =~ /^HTTP_/
257             env[key] = value
258           end
259
260           unless ActionController::Base.respond_to?(:clear_last_instantiation!)
261             ActionController::Base.send(:include, ControllerCapture)
262           end
263
264           ActionController::Base.clear_last_instantiation!
265
266           cgi = MockCGI.new(env, data)
267           Dispatcher.dispatch(cgi, ActionController::CgiRequest::DEFAULT_SESSION_OPTIONS, cgi.stdoutput)
268           @result = cgi.stdoutput.string
269
270           @controller = ActionController::Base.last_instantiation
271           @request = @controller.request
272           @response = @controller.response
273
274           # Decorate the response with the standard behavior of the TestResponse
275           # so that things like assert_response can be used in integration
276           # tests.
277           @response.extend(TestResponseBehavior)
278
279           @html_document = nil
280
281           parse_result
282           return status
283         end
284
285         # Parses the result of the response and extracts the various values,
286         # like cookies, status, headers, etc.
287         def parse_result
288           headers, result_body = @result.split(/\r\n\r\n/, 2)
289
290           @headers = Hash.new { |h,k| h[k] = [] }
291           headers.each_line do |line|
292             key, value = line.strip.split(/:\s*/, 2)
293             @headers[key.downcase] << value
294           end
295
296           (@headers['set-cookie'] || [] ).each do |string|
297             name, value = string.match(/^(.*?)=(.*?);/)[1,2]
298             @cookies[name] = value
299           end
300
301           @status, @status_message = @headers["status"].first.split(/ /)
302           @status = @status.to_i
303         end
304
305         # Encode the cookies hash in a format suitable for passing to a
306         # request.
307         def encode_cookies
308           cookies.inject("") do |string, (name, value)|
309             string << "#{name}=#{value}; "
310           end
311         end
312
313         # Get a temporarly URL writer object
314         def generic_url_rewriter
315           cgi = MockCGI.new('REQUEST_METHOD' => "GET",
316                             'QUERY_STRING'   => "",
317                             "REQUEST_URI"    => "/",
318                             "HTTP_HOST"      => host,
319                             "SERVER_PORT"    => https? ? "443" : "80",
320                             "HTTPS"          => https? ? "on" : "off")                         
321           ActionController::UrlRewriter.new(ActionController::CgiRequest.new(cgi), {})
322         end
323
324         def name_with_prefix(prefix, name)
325           prefix ? "#{prefix}[#{name}]" : name.to_s
326         end
327
328         # Convert the given parameters to a request string. The parameters may
329         # be a string, +nil+, or a Hash.
330         def requestify(parameters, prefix=nil)
331           if Hash === parameters
332             return nil if parameters.empty?
333             parameters.map { |k,v| requestify(v, name_with_prefix(prefix, k)) }.join("&")
334           elsif Array === parameters
335             parameters.map { |v| requestify(v, name_with_prefix(prefix, "")) }.join("&")
336           elsif prefix.nil?
337             parameters
338           else
339             "#{CGI.escape(prefix)}=#{CGI.escape(parameters.to_s)}"
340           end
341         end
342
343     end
344
345     # A module used to extend ActionController::Base, so that integration tests
346     # can capture the controller used to satisfy a request.
347     module ControllerCapture #:nodoc:
348       def self.included(base)
349         base.extend(ClassMethods)
350         base.class_eval do
351           class << self
352             alias_method_chain :new, :capture
353           end
354         end
355       end
356
357       module ClassMethods #:nodoc:
358         mattr_accessor :last_instantiation
359
360         def clear_last_instantiation!
361           self.last_instantiation = nil
362         end
363
364         def new_with_capture(*args)
365           controller = new_without_capture(*args)
366           self.last_instantiation ||= controller
367           controller
368         end
369       end
370     end
371   end
372
373   # An IntegrationTest is one that spans multiple controllers and actions,
374   # tying them all together to ensure they work together as expected. It tests
375   # more completely than either unit or functional tests do, exercising the
376   # entire stack, from the dispatcher to the database.
377   #
378   # At its simplest, you simply extend IntegrationTest and write your tests
379   # using the get/post methods:
380   #
381   #   require "#{File.dirname(__FILE__)}/test_helper"
382   #
383   #   class ExampleTest < ActionController::IntegrationTest
384   #     fixtures :people
385   #
386   #     def test_login
387   #       # get the login page
388   #       get "/login"
389   #       assert_equal 200, status
390   #
391   #       # post the login and follow through to the home page
392   #       post "/login", :username => people(:jamis).username,
393   #         :password => people(:jamis).password
394   #       follow_redirect!
395   #       assert_equal 200, status
396   #       assert_equal "/home", path
397   #     end
398   #   end
399   #
400   # However, you can also have multiple session instances open per test, and
401   # even extend those instances with assertions and methods to create a very
402   # powerful testing DSL that is specific for your application. You can even
403   # reference any named routes you happen to have defined!
404   #
405   #   require "#{File.dirname(__FILE__)}/test_helper"
406   #
407   #   class AdvancedTest < ActionController::IntegrationTest
408   #     fixtures :people, :rooms
409   #
410   #     def test_login_and_speak
411   #       jamis, david = login(:jamis), login(:david)
412   #       room = rooms(:office)
413   #
414   #       jamis.enter(room)
415   #       jamis.speak(room, "anybody home?")
416   #
417   #       david.enter(room)
418   #       david.speak(room, "hello!")
419   #     end
420   #
421   #     private
422   #
423   #       module CustomAssertions
424   #         def enter(room)
425   #           # reference a named route, for maximum internal consistency!
426   #           get(room_url(:id => room.id))
427   #           assert(...)
428   #           ...
429   #         end
430   #
431   #         def speak(room, message)
432   #           xml_http_request "/say/#{room.id}", :message => message
433   #           assert(...)
434   #           ...
435   #         end
436   #       end
437   #
438   #       def login(who)
439   #         open_session do |sess|
440   #           sess.extend(CustomAssertions)
441   #           who = people(who)
442   #           sess.post "/login", :username => who.username,
443   #             :password => who.password
444   #           assert(...)
445   #         end
446   #       end
447   #   end
448   class IntegrationTest < Test::Unit::TestCase
449     # Work around a bug in test/unit caused by the default test being named
450     # as a symbol (:default_test), which causes regex test filters
451     # (like "ruby test.rb -n /foo/") to fail because =~ doesn't work on
452     # symbols.
453     def initialize(name) #:nodoc:
454       super(name.to_s)
455     end
456
457     # Work around test/unit's requirement that every subclass of TestCase have
458     # at least one test method. Note that this implementation extends to all
459     # subclasses, as well, so subclasses of IntegrationTest may also exist
460     # without any test methods.
461     def run(*args) #:nodoc:
462       return if @method_name == "default_test"
463       super
464     end
465
466     # Because of how use_instantiated_fixtures and use_transactional_fixtures
467     # are defined, we need to treat them as special cases. Otherwise, users
468     # would potentially have to set their values for both Test::Unit::TestCase
469     # ActionController::IntegrationTest, since by the time the value is set on
470     # TestCase, IntegrationTest has already been defined and cannot inherit
471     # changes to those variables. So, we make those two attributes copy-on-write.
472
473     class << self
474       def use_transactional_fixtures=(flag) #:nodoc:
475         @_use_transactional_fixtures = true
476         @use_transactional_fixtures = flag
477       end
478
479       def use_instantiated_fixtures=(flag) #:nodoc:
480         @_use_instantiated_fixtures = true
481         @use_instantiated_fixtures = flag
482       end
483
484       def use_transactional_fixtures #:nodoc:
485         @_use_transactional_fixtures ?
486           @use_transactional_fixtures :
487           superclass.use_transactional_fixtures
488       end
489
490       def use_instantiated_fixtures #:nodoc:
491         @_use_instantiated_fixtures ?
492           @use_instantiated_fixtures :
493           superclass.use_instantiated_fixtures
494       end
495     end
496
497     # Reset the current session. This is useful for testing multiple sessions
498     # in a single test case.
499     def reset!
500       @integration_session = open_session
501     end
502
503     %w(get post put head delete cookies assigns xml_http_request).each do |method|
504       define_method(method) do |*args|
505         reset! unless @integration_session
506         # reset the html_document variable, but only for new get/post calls
507         @html_document = nil unless %w(cookies assigns).include?(method)
508         returning @integration_session.send(method, *args) do
509           copy_session_variables!
510         end
511       end
512     end
513
514     # Open a new session instance. If a block is given, the new session is
515     # yielded to the block before being returned.
516     #
517     #   session = open_session do |sess|
518     #     sess.extend(CustomAssertions)
519     #   end
520     #
521     # By default, a single session is automatically created for you, but you
522     # can use this method to open multiple sessions that ought to be tested
523     # simultaneously.
524     def open_session
525       session = Integration::Session.new
526
527       # delegate the fixture accessors back to the test instance
528       extras = Module.new { attr_accessor :delegate, :test_result }
529       self.class.fixture_table_names.each do |table_name|
530         name = table_name.tr(".", "_")
531         next unless respond_to?(name)
532         extras.send(:define_method, name) { |*args| delegate.send(name, *args) }
533       end
534
535       # delegate add_assertion to the test case
536       extras.send(:define_method, :add_assertion) { test_result.add_assertion }
537       session.extend(extras)
538       session.delegate = self
539       session.test_result = @_result
540
541       yield session if block_given?
542       session
543     end
544
545     # Copy the instance variables from the current session instance into the
546     # test instance.
547     def copy_session_variables! #:nodoc:
548       return unless @integration_session
549       %w(controller response request).each do |var|
550         instance_variable_set("@#{var}", @integration_session.send(var))
551       end
552     end
553
554     # Delegate unhandled messages to the current session instance.
555     def method_missing(sym, *args, &block)
556       reset! unless @integration_session
557       returning @integration_session.send(sym, *args, &block) do
558         copy_session_variables!
559       end
560     end
561   end
562 end
Note: See TracBrowser for help on using the browser.