Fixed #4476 -- Added a ``follow`` option to the test client request methods. This implements browser-like behavior for the test client, following redirect chains when a 30X response is received. Thanks to Marc Fargas and Keith Bussell for their work on this.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@9911 bcc190cf-cafb-0310-a4f2-bffc1f526a37
This commit is contained in:
Russell Keith-Magee 2009-02-27 13:14:59 +00:00
parent e20f09c2d0
commit e735fe7160
7 changed files with 298 additions and 76 deletions

View File

@ -1,5 +1,5 @@
import urllib
from urlparse import urlparse, urlunparse
from urlparse import urlparse, urlunparse, urlsplit
import sys
import os
try:
@ -12,7 +12,7 @@ from django.contrib.auth import authenticate, login
from django.core.handlers.base import BaseHandler
from django.core.handlers.wsgi import WSGIRequest
from django.core.signals import got_request_exception
from django.http import SimpleCookie, HttpRequest
from django.http import SimpleCookie, HttpRequest, QueryDict
from django.template import TemplateDoesNotExist
from django.test import signals
from django.utils.functional import curry
@ -261,7 +261,7 @@ class Client(object):
return response
def get(self, path, data={}, **extra):
def get(self, path, data={}, follow=False, **extra):
"""
Requests a response from the server using GET.
"""
@ -275,9 +275,13 @@ class Client(object):
}
r.update(extra)
return self.request(**r)
response = self.request(**r)
if follow:
response = self._handle_redirects(response)
return response
def post(self, path, data={}, content_type=MULTIPART_CONTENT, **extra):
def post(self, path, data={}, content_type=MULTIPART_CONTENT,
follow=False, **extra):
"""
Requests a response from the server using POST.
"""
@ -297,9 +301,12 @@ class Client(object):
}
r.update(extra)
return self.request(**r)
response = self.request(**r)
if follow:
response = self._handle_redirects(response)
return response
def head(self, path, data={}, **extra):
def head(self, path, data={}, follow=False, **extra):
"""
Request a response from the server using HEAD.
"""
@ -313,9 +320,12 @@ class Client(object):
}
r.update(extra)
return self.request(**r)
response = self.request(**r)
if follow:
response = self._handle_redirects(response)
return response
def options(self, path, data={}, **extra):
def options(self, path, data={}, follow=False, **extra):
"""
Request a response from the server using OPTIONS.
"""
@ -328,9 +338,13 @@ class Client(object):
}
r.update(extra)
return self.request(**r)
response = self.request(**r)
if follow:
response = self._handle_redirects(response)
return response
def put(self, path, data={}, content_type=MULTIPART_CONTENT, **extra):
def put(self, path, data={}, content_type=MULTIPART_CONTENT,
follow=False, **extra):
"""
Send a resource to the server using PUT.
"""
@ -350,9 +364,12 @@ class Client(object):
}
r.update(extra)
return self.request(**r)
response = self.request(**r)
if follow:
response = self._handle_redirects(response)
return response
def delete(self, path, data={}, **extra):
def delete(self, path, data={}, follow=False, **extra):
"""
Send a DELETE request to the server.
"""
@ -365,7 +382,10 @@ class Client(object):
}
r.update(extra)
return self.request(**r)
response = self.request(**r)
if follow:
response = self._handle_redirects(response)
return response
def login(self, **credentials):
"""
@ -416,3 +436,27 @@ class Client(object):
session = __import__(settings.SESSION_ENGINE, {}, {}, ['']).SessionStore()
session.delete(session_key=self.cookies[settings.SESSION_COOKIE_NAME].value)
self.cookies = SimpleCookie()
def _handle_redirects(self, response):
"Follows any redirects by requesting responses from the server using GET."
response.redirect_chain = []
while response.status_code in (301, 302, 303, 307):
url = response['Location']
scheme, netloc, path, query, fragment = urlsplit(url)
redirect_chain = response.redirect_chain
redirect_chain.append((url, response.status_code))
# The test client doesn't handle external links,
# but since the situation is simulated in test_client,
# we fake things here by ignoring the netloc portion of the
# redirected URL.
response = self.get(path, QueryDict(query), follow=False)
response.redirect_chain = redirect_chain
# Prevent loops
if response.redirect_chain[-1] in response.redirect_chain[0:-1]:
break
return response

View File

@ -43,7 +43,7 @@ def disable_transaction_methods():
transaction.savepoint_commit = nop
transaction.savepoint_rollback = nop
transaction.enter_transaction_management = nop
transaction.leave_transaction_management = nop
transaction.leave_transaction_management = nop
def restore_transaction_methods():
transaction.commit = real_commit
@ -198,7 +198,7 @@ class DocTestRunner(doctest.DocTestRunner):
# Rollback, in case of database errors. Otherwise they'd have
# side effects on other tests.
transaction.rollback_unless_managed()
class TransactionTestCase(unittest.TestCase):
def _pre_setup(self):
"""Performs any pre-test setup. This includes:
@ -242,7 +242,7 @@ class TransactionTestCase(unittest.TestCase):
import sys
result.addError(self, sys.exc_info())
return
super(TransactionTestCase, self).__call__(result)
super(TransactionTestCase, self).__call__(result)
try:
self._post_teardown()
except (KeyboardInterrupt, SystemExit):
@ -263,7 +263,7 @@ class TransactionTestCase(unittest.TestCase):
def _fixture_teardown(self):
pass
def _urlconf_teardown(self):
def _urlconf_teardown(self):
if hasattr(self, '_old_root_urlconf'):
settings.ROOT_URLCONF = self._old_root_urlconf
clear_url_caches()
@ -276,25 +276,48 @@ class TransactionTestCase(unittest.TestCase):
Note that assertRedirects won't work for external links since it uses
TestClient to do a request.
"""
self.assertEqual(response.status_code, status_code,
("Response didn't redirect as expected: Response code was %d"
" (expected %d)" % (response.status_code, status_code)))
url = response['Location']
scheme, netloc, path, query, fragment = urlsplit(url)
if hasattr(response, 'redirect_chain'):
# The request was a followed redirect
self.assertTrue(len(response.redirect_chain) > 0,
("Response didn't redirect as expected: Response code was %d"
" (expected %d)" % (response.status_code, status_code)))
self.assertEqual(response.redirect_chain[0][1], status_code,
("Initial response didn't redirect as expected: Response code was %d"
" (expected %d)" % (response.redirect_chain[0][1], status_code)))
url, status_code = response.redirect_chain[-1]
self.assertEqual(response.status_code, target_status_code,
("Response didn't redirect as expected: Final Response code was %d"
" (expected %d)" % (response.status_code, target_status_code)))
else:
# Not a followed redirect
self.assertEqual(response.status_code, status_code,
("Response didn't redirect as expected: Response code was %d"
" (expected %d)" % (response.status_code, status_code)))
url = response['Location']
scheme, netloc, path, query, fragment = urlsplit(url)
redirect_response = response.client.get(path, QueryDict(query))
# Get the redirection page, using the same client that was used
# to obtain the original response.
self.assertEqual(redirect_response.status_code, target_status_code,
("Couldn't retrieve redirection page '%s': response code was %d"
" (expected %d)") %
(path, redirect_response.status_code, target_status_code))
e_scheme, e_netloc, e_path, e_query, e_fragment = urlsplit(expected_url)
if not (e_scheme or e_netloc):
expected_url = urlunsplit(('http', host or 'testserver', e_path,
e_query, e_fragment))
e_query, e_fragment))
self.assertEqual(url, expected_url,
"Response redirected to '%s', expected '%s'" % (url, expected_url))
# Get the redirection page, using the same client that was used
# to obtain the original response.
redirect_response = response.client.get(path, QueryDict(query))
self.assertEqual(redirect_response.status_code, target_status_code,
("Couldn't retrieve redirection page '%s': response code was %d"
" (expected %d)") %
(path, redirect_response.status_code, target_status_code))
def assertContains(self, response, text, count=None, status_code=200):
"""
@ -401,15 +424,15 @@ class TransactionTestCase(unittest.TestCase):
class TestCase(TransactionTestCase):
"""
Does basically the same as TransactionTestCase, but surrounds every test
with a transaction, monkey-patches the real transaction management routines to
do nothing, and rollsback the test transaction at the end of the test. You have
with a transaction, monkey-patches the real transaction management routines to
do nothing, and rollsback the test transaction at the end of the test. You have
to use TransactionTestCase, if you need transaction management inside a test.
"""
def _fixture_setup(self):
if not settings.DATABASE_SUPPORTS_TRANSACTIONS:
return super(TestCase, self)._fixture_setup()
transaction.enter_transaction_management()
transaction.managed(True)
disable_transaction_methods()
@ -426,7 +449,7 @@ class TestCase(TransactionTestCase):
def _fixture_teardown(self):
if not settings.DATABASE_SUPPORTS_TRANSACTIONS:
return super(TestCase, self)._fixture_teardown()
restore_transaction_methods()
transaction.rollback()
transaction.leave_transaction_management()

View File

@ -478,7 +478,8 @@ arguments at time of construction:
Once you have a ``Client`` instance, you can call any of the following
methods:
.. method:: Client.get(path, data={})
.. method:: Client.get(path, data={}, follow=False)
Makes a GET request on the provided ``path`` and returns a ``Response``
object, which is documented below.
@ -505,7 +506,18 @@ arguments at time of construction:
If you provide URL both an encoded GET data and a data argument,
the data argument will take precedence.
.. method:: Client.post(path, data={}, content_type=MULTIPART_CONTENT)
If you set ``follow`` to ``True`` the client will follow any redirects
and a ``redirect_chain`` attribute will be set in the response object
containing tuples of the intermediate urls and status codes.
If you had an url ``/redirect_me/`` that redirected to ``/next/``, that
redirected to ``/final/``, this is what you'd see::
>>> response = c.get('/redirect_me/')
>>> response.redirect_chain
[(u'http://testserver/next/', 302), (u'http://testserver/final/', 302)]
.. method:: Client.post(path, data={}, content_type=MULTIPART_CONTENT, follow=False)
Makes a POST request on the provided ``path`` and returns a
``Response`` object, which is documented below.
@ -556,7 +568,7 @@ arguments at time of construction:
Note that you should manually close the file after it has been provided
to ``post()``.
.. versionadded:: development
.. versionchanged:: 1.1
If the URL you request with a POST contains encoded parameters, these
parameters will be made available in the request.GET data. For example,
@ -568,7 +580,11 @@ arguments at time of construction:
to retrieve the username and password, and could interrogate request.GET
to determine if the user was a visitor.
.. method:: Client.head(path, data={})
If you set ``follow`` to ``True`` the client will follow any redirects
and a ``redirect_chain`` attribute will be set in the response object
containing tuples of the intermediate urls and status codes.
.. method:: Client.head(path, data={}, follow=False)
.. versionadded:: development
@ -576,14 +592,22 @@ arguments at time of construction:
object. Useful for testing RESTful interfaces. Acts just like
:meth:`Client.get` except it does not return a message body.
.. method:: Client.options(path, data={})
If you set ``follow`` to ``True`` the client will follow any redirects
and a ``redirect_chain`` attribute will be set in the response object
containing tuples of the intermediate urls and status codes.
.. method:: Client.options(path, data={}, follow=False)
.. versionadded:: development
Makes an OPTIONS request on the provided ``path`` and returns a
``Response`` object. Useful for testing RESTful interfaces.
.. method:: Client.put(path, data={}, content_type=MULTIPART_CONTENT)
If you set ``follow`` to ``True`` the client will follow any redirects
and a ``redirect_chain`` attribute will be set in the response object
containing tuples of the intermediate urls and status codes.
.. method:: Client.put(path, data={}, content_type=MULTIPART_CONTENT, follow=False)
.. versionadded:: development
@ -591,13 +615,21 @@ arguments at time of construction:
``Response`` object. Useful for testing RESTful interfaces. Acts just
like :meth:`Client.post` except with the PUT request method.
.. method:: Client.delete(path)
If you set ``follow`` to ``True`` the client will follow any redirects
and a ``redirect_chain`` attribute will be set in the response object
containing tuples of the intermediate urls and status codes.
.. method:: Client.delete(path, follow=False)
.. versionadded:: development
Makes an DELETE request on the provided ``path`` and returns a
``Response`` object. Useful for testing RESTful interfaces.
If you set ``follow`` to ``True`` the client will follow any redirects
and a ``redirect_chain`` attribute will be set in the response object
containing tuples of the intermediate urls and status codes.
.. method:: Client.login(**credentials)
.. versionadded:: 1.0
@ -789,47 +821,47 @@ additions.
.. class:: TransactionTestCase()
Django ``TestCase`` classes make use of database transaction facilities, if
available, to speed up the process of resetting the database to a known state
at the beginning of each test. A consequence of this, however, is that the
effects of transaction commit and rollback cannot be tested by a Django
``TestCase`` class. If your test requires testing of such transactional
Django ``TestCase`` classes make use of database transaction facilities, if
available, to speed up the process of resetting the database to a known state
at the beginning of each test. A consequence of this, however, is that the
effects of transaction commit and rollback cannot be tested by a Django
``TestCase`` class. If your test requires testing of such transactional
behavior, you should use a Django ``TransactionTestCase``.
``TransactionTestCase`` and ``TestCase`` are identical except for the manner
in which the database is reset to a known state and the ability for test code
to test the effects of commit and rollback. A ``TranscationTestCase`` resets
the database before the test runs by truncating all tables and reloading
initial data. A ``TransactionTestCase`` may call commit and rollback and
observe the effects of these calls on the database.
``TransactionTestCase`` and ``TestCase`` are identical except for the manner
in which the database is reset to a known state and the ability for test code
to test the effects of commit and rollback. A ``TranscationTestCase`` resets
the database before the test runs by truncating all tables and reloading
initial data. A ``TransactionTestCase`` may call commit and rollback and
observe the effects of these calls on the database.
A ``TestCase``, on the other hand, does not truncate tables and reload initial
data at the beginning of a test. Instead, it encloses the test code in a
database transaction that is rolled back at the end of the test. It also
prevents the code under test from issuing any commit or rollback operations
on the database, to ensure that the rollback at the end of the test restores
the database to its initial state. In order to guarantee that all ``TestCase``
code starts with a clean database, the Django test runner runs all ``TestCase``
tests first, before any other tests (e.g. doctests) that may alter the
A ``TestCase``, on the other hand, does not truncate tables and reload initial
data at the beginning of a test. Instead, it encloses the test code in a
database transaction that is rolled back at the end of the test. It also
prevents the code under test from issuing any commit or rollback operations
on the database, to ensure that the rollback at the end of the test restores
the database to its initial state. In order to guarantee that all ``TestCase``
code starts with a clean database, the Django test runner runs all ``TestCase``
tests first, before any other tests (e.g. doctests) that may alter the
database without restoring it to its original state.
When running on a database that does not support rollback (e.g. MySQL with the
MyISAM storage engine), ``TestCase`` falls back to initializing the database
When running on a database that does not support rollback (e.g. MySQL with the
MyISAM storage engine), ``TestCase`` falls back to initializing the database
by truncating tables and reloading initial data.
.. note::
The ``TestCase`` use of rollback to un-do the effects of the test code
may reveal previously-undetected errors in test code. For example,
test code that assumes primary keys values will be assigned starting at
one may find that assumption no longer holds true when rollbacks instead
of table truncation are being used to reset the database. Similarly,
the reordering of tests so that all ``TestCase`` classes run first may
reveal unexpected dependencies on test case ordering. In such cases a
The ``TestCase`` use of rollback to un-do the effects of the test code
may reveal previously-undetected errors in test code. For example,
test code that assumes primary keys values will be assigned starting at
one may find that assumption no longer holds true when rollbacks instead
of table truncation are being used to reset the database. Similarly,
the reordering of tests so that all ``TestCase`` classes run first may
reveal unexpected dependencies on test case ordering. In such cases a
quick fix is to switch the ``TestCase`` to a ``TransactionTestCase``.
A better long-term fix, that allows the test to take advantage of the
speed benefit of ``TestCase``, is to fix the underlying test problem.
Default test client
~~~~~~~~~~~~~~~~~~~
@ -1028,9 +1060,15 @@ applications:
.. method:: assertRedirects(response, expected_url, status_code=302, target_status_code=200)
Asserts that the response return a ``status_code`` redirect status, it
redirected to ``expected_url`` (including any GET data), and the subsequent
redirected to ``expected_url`` (including any GET data), and the final
page was received with ``target_status_code``.
.. versionadded:: 1.1
If your request used the ``follow`` argument, the ``expected_url`` and
``target_status_code`` will be the url and status code for the final
point of the redirect chain.
E-mail services
---------------

View File

@ -70,13 +70,13 @@ class ClientTest(TestCase):
self.assertEqual(response.context['data'], '37')
self.assertEqual(response.template.name, 'POST Template')
self.failUnless('Data received' in response.content)
def test_response_headers(self):
"Check the value of HTTP headers returned in a response"
response = self.client.get("/test_client/header_view/")
self.assertEquals(response['X-DJANGO-TEST'], 'Slartibartfast')
def test_raw_post(self):
"POST raw data (with a content type) to a view"
test_doc = """<?xml version="1.0" encoding="utf-8"?><library><book><title>Blink</title><author>Malcolm Gladwell</author></book></library>"""
@ -132,6 +132,12 @@ class ClientTest(TestCase):
# the attempt to get the redirection location returned 301 when retrieved
self.assertRedirects(response, 'http://testserver/test_client/permanent_redirect_view/', target_status_code=301)
def test_follow_redirect(self):
"A URL that redirects can be followed to termination."
response = self.client.get('/test_client/double_redirect_view/', follow=True)
self.assertRedirects(response, 'http://testserver/test_client/get_view/', status_code=302, target_status_code=200)
self.assertEquals(len(response.redirect_chain), 2)
def test_notfound_response(self):
"GET a URL that responds as '404:Not Found'"
response = self.client.get('/test_client/bad_view/')

View File

@ -148,6 +148,107 @@ class AssertRedirectsTests(TestCase):
except AssertionError, e:
self.assertEquals(str(e), "Couldn't retrieve redirection page '/test_client/permanent_redirect_view/': response code was 301 (expected 200)")
def test_redirect_chain(self):
"You can follow a redirect chain of multiple redirects"
response = self.client.get('/test_client_regress/redirects/further/more/', {}, follow=True)
self.assertRedirects(response, '/test_client_regress/no_template_view/',
status_code=301, target_status_code=200)
self.assertEquals(len(response.redirect_chain), 1)
self.assertEquals(response.redirect_chain[0], ('http://testserver/test_client_regress/no_template_view/', 301))
def test_multiple_redirect_chain(self):
"You can follow a redirect chain of multiple redirects"
response = self.client.get('/test_client_regress/redirects/', {}, follow=True)
self.assertRedirects(response, '/test_client_regress/no_template_view/',
status_code=301, target_status_code=200)
self.assertEquals(len(response.redirect_chain), 3)
self.assertEquals(response.redirect_chain[0], ('http://testserver/test_client_regress/redirects/further/', 301))
self.assertEquals(response.redirect_chain[1], ('http://testserver/test_client_regress/redirects/further/more/', 301))
self.assertEquals(response.redirect_chain[2], ('http://testserver/test_client_regress/no_template_view/', 301))
def test_redirect_chain_to_non_existent(self):
"You can follow a chain to a non-existent view"
response = self.client.get('/test_client_regress/redirect_to_non_existent_view2/', {}, follow=True)
self.assertRedirects(response, '/test_client_regress/non_existent_view/',
status_code=301, target_status_code=404)
def test_redirect_chain_to_self(self):
"Redirections to self are caught and escaped"
response = self.client.get('/test_client_regress/redirect_to_self/', {}, follow=True)
# The chain of redirects stops once the cycle is detected.
self.assertRedirects(response, '/test_client_regress/redirect_to_self/',
status_code=301, target_status_code=301)
self.assertEquals(len(response.redirect_chain), 2)
def test_circular_redirect(self):
"Circular redirect chains are caught and escaped"
response = self.client.get('/test_client_regress/circular_redirect_1/', {}, follow=True)
# The chain of redirects will get back to the starting point, but stop there.
self.assertRedirects(response, '/test_client_regress/circular_redirect_2/',
status_code=301, target_status_code=301)
self.assertEquals(len(response.redirect_chain), 4)
def test_redirect_chain_post(self):
"A redirect chain will be followed from an initial POST post"
response = self.client.post('/test_client_regress/redirects/',
{'nothing': 'to_send'}, follow=True)
self.assertRedirects(response,
'/test_client_regress/no_template_view/', 301, 200)
self.assertEquals(len(response.redirect_chain), 3)
def test_redirect_chain_head(self):
"A redirect chain will be followed from an initial HEAD request"
response = self.client.head('/test_client_regress/redirects/',
{'nothing': 'to_send'}, follow=True)
self.assertRedirects(response,
'/test_client_regress/no_template_view/', 301, 200)
self.assertEquals(len(response.redirect_chain), 3)
def test_redirect_chain_options(self):
"A redirect chain will be followed from an initial OPTIONS request"
response = self.client.options('/test_client_regress/redirects/',
{'nothing': 'to_send'}, follow=True)
self.assertRedirects(response,
'/test_client_regress/no_template_view/', 301, 200)
self.assertEquals(len(response.redirect_chain), 3)
def test_redirect_chain_put(self):
"A redirect chain will be followed from an initial PUT request"
response = self.client.put('/test_client_regress/redirects/',
{'nothing': 'to_send'}, follow=True)
self.assertRedirects(response,
'/test_client_regress/no_template_view/', 301, 200)
self.assertEquals(len(response.redirect_chain), 3)
def test_redirect_chain_delete(self):
"A redirect chain will be followed from an initial DELETE request"
response = self.client.delete('/test_client_regress/redirects/',
{'nothing': 'to_send'}, follow=True)
self.assertRedirects(response,
'/test_client_regress/no_template_view/', 301, 200)
self.assertEquals(len(response.redirect_chain), 3)
def test_redirect_chain_on_non_redirect_page(self):
"An assertion is raised if the original page couldn't be retrieved as expected"
# This page will redirect with code 301, not 302
response = self.client.get('/test_client/get_view/', follow=True)
try:
self.assertRedirects(response, '/test_client/get_view/')
except AssertionError, e:
self.assertEquals(str(e), "Response didn't redirect as expected: Response code was 200 (expected 302)")
def test_redirect_on_non_redirect_page(self):
"An assertion is raised if the original page couldn't be retrieved as expected"
# This page will redirect with code 301, not 302
response = self.client.get('/test_client/get_view/')
try:
self.assertRedirects(response, '/test_client/get_view/')
except AssertionError, e:
self.assertEquals(str(e), "Response didn't redirect as expected: Response code was 200 (expected 302)")
class AssertFormErrorTests(TestCase):
def test_unknown_form(self):
"An assertion is raised if the form name is unknown"

View File

@ -1,4 +1,5 @@
from django.conf.urls.defaults import *
from django.views.generic.simple import redirect_to
import views
urlpatterns = patterns('',
@ -8,6 +9,15 @@ urlpatterns = patterns('',
(r'^request_data/$', views.request_data),
url(r'^arg_view/(?P<name>.+)/$', views.view_with_argument, name='arg_view'),
(r'^login_protected_redirect_view/$', views.login_protected_redirect_view),
(r'^redirects/$', redirect_to, {'url': '/test_client_regress/redirects/further/'}),
(r'^redirects/further/$', redirect_to, {'url': '/test_client_regress/redirects/further/more/'}),
(r'^redirects/further/more/$', redirect_to, {'url': '/test_client_regress/no_template_view/'}),
(r'^redirect_to_non_existent_view/$', redirect_to, {'url': '/test_client_regress/non_existent_view/'}),
(r'^redirect_to_non_existent_view2/$', redirect_to, {'url': '/test_client_regress/redirect_to_non_existent_view/'}),
(r'^redirect_to_self/$', redirect_to, {'url': '/test_client_regress/redirect_to_self/'}),
(r'^circular_redirect_1/$', redirect_to, {'url': '/test_client_regress/circular_redirect_2/'}),
(r'^circular_redirect_2/$', redirect_to, {'url': '/test_client_regress/circular_redirect_3/'}),
(r'^circular_redirect_3/$', redirect_to, {'url': '/test_client_regress/circular_redirect_1/'}),
(r'^set_session/$', views.set_session_view),
(r'^check_session/$', views.check_session_view),
(r'^request_methods/$', views.request_methods_view),

View File

@ -58,4 +58,4 @@ def check_session_view(request):
def request_methods_view(request):
"A view that responds with the request method"
return HttpResponse('request method: %s' % request.method)
return HttpResponse('request method: %s' % request.method)