summaryrefslogtreecommitdiff
path: root/django
diff options
context:
space:
mode:
authorJulien Phalip <jphalip@gmail.com>2011-12-22 08:33:58 +0000
committerJulien Phalip <jphalip@gmail.com>2011-12-22 08:33:58 +0000
commit2f02a05ffb45be68b4164b4785ff1826833150a3 (patch)
treed51f7454aeb97a5c35b3045d5d5413691aaf1d00 /django
parent45e3dff5ac697f16829697bc2a899eaeac8986ea (diff)
Fixed #2879 -- Added support for the integration with Selenium and other in-browser testing frameworks. Also added the first Selenium tests for `contrib.admin`. Many thanks to everyone for their contributions and feedback: Mikeal Rogers, Dirk Datzert, mir, Simon G., Almad, Russell Keith-Magee, Denis Golomazov, devin, robertrv, andrewbadr, Idan Gazit, voidspace, Tom Christie, hjwp2, Adam Nelson, Jannis Leidel, Anssi Kääriäinen, Preston Holmes, Bruno Renié and Jacob Kaplan-Moss.
git-svn-id: http://code.djangoproject.com/svn/django/trunk@17241 bcc190cf-cafb-0310-a4f2-bffc1f526a37
Diffstat (limited to 'django')
-rw-r--r--django/contrib/admin/tests.py52
-rw-r--r--django/core/management/commands/test.py37
-rw-r--r--django/test/__init__.py3
-rw-r--r--django/test/testcases.py245
4 files changed, 324 insertions, 13 deletions
diff --git a/django/contrib/admin/tests.py b/django/contrib/admin/tests.py
new file mode 100644
index 0000000000..2a3f0c3cd1
--- /dev/null
+++ b/django/contrib/admin/tests.py
@@ -0,0 +1,52 @@
+import sys
+
+from django.test import LiveServerTestCase
+from django.utils.importlib import import_module
+from django.utils.unittest import SkipTest
+from django.utils.translation import ugettext as _
+
+class AdminSeleniumWebDriverTestCase(LiveServerTestCase):
+ webdriver_class = 'selenium.webdriver.firefox.webdriver.WebDriver'
+
+ @classmethod
+ def setUpClass(cls):
+ if sys.version_info < (2, 6):
+ raise SkipTest('Selenium Webdriver does not support Python < 2.6.')
+ try:
+ # Import and start the WebDriver class.
+ module, attr = cls.webdriver_class.rsplit('.', 1)
+ mod = import_module(module)
+ WebDriver = getattr(mod, attr)
+ cls.selenium = WebDriver()
+ except Exception:
+ raise SkipTest('Selenium webdriver "%s" not installed or not '
+ 'operational.' % cls.webdriver_class)
+ super(AdminSeleniumWebDriverTestCase, cls).setUpClass()
+
+ @classmethod
+ def tearDownClass(cls):
+ super(AdminSeleniumWebDriverTestCase, cls).tearDownClass()
+ if hasattr(cls, 'selenium'):
+ cls.selenium.quit()
+
+ def admin_login(self, username, password, login_url='/admin/'):
+ """
+ Helper function to log into the admin.
+ """
+ self.selenium.get('%s%s' % (self.live_server_url, login_url))
+ username_input = self.selenium.find_element_by_name('username')
+ username_input.send_keys(username)
+ password_input = self.selenium.find_element_by_name('password')
+ password_input.send_keys(password)
+ login_text = _('Log in')
+ self.selenium.find_element_by_xpath(
+ '//input[@value="%s"]' % login_text).click()
+
+ def get_css_value(self, selector, attribute):
+ """
+ Helper function that returns the value for the CSS attribute of an
+ DOM element specified by the given selector. Uses the jQuery that ships
+ with Django.
+ """
+ return self.selenium.execute_script(
+ 'return django.jQuery("%s").css("%s")' % (selector, attribute)) \ No newline at end of file
diff --git a/django/core/management/commands/test.py b/django/core/management/commands/test.py
index 2a6dbfc387..ad49184e8a 100644
--- a/django/core/management/commands/test.py
+++ b/django/core/management/commands/test.py
@@ -1,20 +1,32 @@
+import sys
+import os
+from optparse import make_option, OptionParser
+
from django.conf import settings
from django.core.management.base import BaseCommand
-from optparse import make_option, OptionParser
-import sys
from django.test.utils import get_runner
class Command(BaseCommand):
option_list = BaseCommand.option_list + (
- make_option('--noinput', action='store_false', dest='interactive', default=True,
+ make_option('--noinput',
+ action='store_false', dest='interactive', default=True,
help='Tells Django to NOT prompt the user for input of any kind.'),
- make_option('--failfast', action='store_true', dest='failfast', default=False,
- help='Tells Django to stop running the test suite after first failed test.'),
- make_option('--testrunner', action='store', dest='testrunner',
- help='Tells Django to use specified test runner class instead of the one '+
- 'specified by the TEST_RUNNER setting.')
+ make_option('--failfast',
+ action='store_true', dest='failfast', default=False,
+ help='Tells Django to stop running the test suite after first '
+ 'failed test.'),
+ make_option('--testrunner',
+ action='store', dest='testrunner',
+ help='Tells Django to use specified test runner class instead of '
+ 'the one specified by the TEST_RUNNER setting.'),
+ make_option('--liveserver',
+ action='store', dest='liveserver', default=None,
+ help='Overrides the default address where the live server (used '
+ 'with LiveServerTestCase) is expected to run from. The '
+ 'default value is localhost:8081.'),
)
- help = 'Runs the test suite for the specified applications, or the entire site if no apps are specified.'
+ help = ('Runs the test suite for the specified applications, or the '
+ 'entire site if no apps are specified.')
args = '[appname ...]'
requires_model_validation = False
@@ -35,7 +47,8 @@ class Command(BaseCommand):
def create_parser(self, prog_name, subcommand):
test_runner_class = get_runner(settings, self.test_runner)
- options = self.option_list + getattr(test_runner_class, 'option_list', ())
+ options = self.option_list + getattr(
+ test_runner_class, 'option_list', ())
return OptionParser(prog=prog_name,
usage=self.usage(subcommand),
version=self.get_version(),
@@ -48,6 +61,10 @@ class Command(BaseCommand):
TestRunner = get_runner(settings, options.get('testrunner'))
options['verbosity'] = int(options.get('verbosity'))
+ if options.get('liveserver') is not None:
+ os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] = options['liveserver']
+ del options['liveserver']
+
test_runner = TestRunner(**options)
failures = test_runner.run_tests(test_labels)
diff --git a/django/test/__init__.py b/django/test/__init__.py
index a3a03e3e5d..21a4841a6b 100644
--- a/django/test/__init__.py
+++ b/django/test/__init__.py
@@ -4,5 +4,6 @@ Django Unit Test and Doctest framework.
from django.test.client import Client, RequestFactory
from django.test.testcases import (TestCase, TransactionTestCase,
- SimpleTestCase, skipIfDBFeature, skipUnlessDBFeature)
+ SimpleTestCase, LiveServerTestCase, skipIfDBFeature,
+ skipUnlessDBFeature)
from django.test.utils import Approximate
diff --git a/django/test/testcases.py b/django/test/testcases.py
index ee22ac219e..d77eb39bde 100644
--- a/django/test/testcases.py
+++ b/django/test/testcases.py
@@ -1,16 +1,23 @@
from __future__ import with_statement
+import os
import re
import sys
from functools import wraps
from urlparse import urlsplit, urlunsplit
from xml.dom.minidom import parseString, Node
+import select
+import socket
+import threading
from django.conf import settings
+from django.contrib.staticfiles.handlers import StaticFilesHandler
from django.core import mail
-from django.core.exceptions import ValidationError
+from django.core.exceptions import ValidationError, ImproperlyConfigured
+from django.core.handlers.wsgi import WSGIHandler
from django.core.management import call_command
from django.core.signals import request_started
+from django.core.servers.basehttp import (WSGIRequestHandler, WSGIServer)
from django.core.urlresolvers import clear_url_caches
from django.core.validators import EMPTY_VALUES
from django.db import (transaction, connection, connections, DEFAULT_DB_ALIAS,
@@ -23,6 +30,7 @@ from django.test.utils import (get_warnings_state, restore_warnings_state,
override_settings)
from django.utils import simplejson, unittest as ut2
from django.utils.encoding import smart_str
+from django.views.static import serve
__all__ = ('DocTestRunner', 'OutputChecker', 'TestCase', 'TransactionTestCase',
'SimpleTestCase', 'skipIfDBFeature', 'skipUnlessDBFeature')
@@ -68,7 +76,8 @@ def restore_transaction_methods():
class OutputChecker(doctest.OutputChecker):
def check_output(self, want, got, optionflags):
"""
- The entry method for doctest output checking. Defers to a sequence of child checkers
+ The entry method for doctest output checking. Defers to a sequence of
+ child checkers
"""
checks = (self.check_output_default,
self.check_output_numeric,
@@ -219,6 +228,7 @@ class DocTestRunner(doctest.DocTestRunner):
for conn in connections:
transaction.rollback_unless_managed(using=conn)
+
class _AssertNumQueriesContext(object):
def __init__(self, test_case, num, connection):
self.test_case = test_case
@@ -247,6 +257,7 @@ class _AssertNumQueriesContext(object):
)
)
+
class SimpleTestCase(ut2.TestCase):
def save_warnings_state(self):
@@ -335,6 +346,7 @@ class SimpleTestCase(ut2.TestCase):
self.assertTrue(isinstance(fieldclass(*field_args, **field_kwargs),
fieldclass))
+
class TransactionTestCase(SimpleTestCase):
# The class we'll use for the test client self.client.
# Can be overridden in derived classes.
@@ -643,6 +655,7 @@ class TransactionTestCase(SimpleTestCase):
with context:
func(*args, **kwargs)
+
def connections_support_transactions():
"""
Returns True if all connections support transactions.
@@ -650,6 +663,7 @@ def connections_support_transactions():
return all(conn.features.supports_transactions
for conn in connections.all())
+
class TestCase(TransactionTestCase):
"""
Does basically the same as TransactionTestCase, but surrounds every test
@@ -703,6 +717,7 @@ class TestCase(TransactionTestCase):
transaction.rollback(using=db)
transaction.leave_transaction_management(using=db)
+
def _deferredSkip(condition, reason):
def decorator(test_func):
if not (isinstance(test_func, type) and
@@ -719,6 +734,7 @@ def _deferredSkip(condition, reason):
return test_item
return decorator
+
def skipIfDBFeature(feature):
"""
Skip a test if a database has the named feature
@@ -726,9 +742,234 @@ def skipIfDBFeature(feature):
return _deferredSkip(lambda: getattr(connection.features, feature),
"Database has feature %s" % feature)
+
def skipUnlessDBFeature(feature):
"""
Skip a test unless a database has the named feature
"""
return _deferredSkip(lambda: not getattr(connection.features, feature),
"Database doesn't support feature %s" % feature)
+
+
+class QuietWSGIRequestHandler(WSGIRequestHandler):
+ """
+ Just a regular WSGIRequestHandler except it doesn't log to the standard
+ output any of the requests received, so as to not clutter the output for
+ the tests' results.
+ """
+
+ def log_message(*args):
+ pass
+
+
+class _ImprovedEvent(threading._Event):
+ """
+ Does the same as `threading.Event` except it overrides the wait() method
+ with some code borrowed from Python 2.7 to return the set state of the
+ event (see: http://hg.python.org/cpython/rev/b5aa8aa78c0f/). This allows
+ to know whether the wait() method exited normally or because of the
+ timeout. This class can be removed when Django supports only Python >= 2.7.
+ """
+
+ def wait(self, timeout=None):
+ self._Event__cond.acquire()
+ try:
+ if not self._Event__flag:
+ self._Event__cond.wait(timeout)
+ return self._Event__flag
+ finally:
+ self._Event__cond.release()
+
+
+class StoppableWSGIServer(WSGIServer):
+ """
+ The code in this class is borrowed from the `SocketServer.BaseServer` class
+ in Python 2.6. The important functionality here is that the server is non-
+ blocking and that it can be shut down at any moment. This is made possible
+ by the server regularly polling the socket and checking if it has been
+ asked to stop.
+ Note for the future: Once Django stops supporting Python 2.6, this class
+ can be removed as `WSGIServer` will have this ability to shutdown on
+ demand and will not require the use of the _ImprovedEvent class whose code
+ is borrowed from Python 2.7.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super(StoppableWSGIServer, self).__init__(*args, **kwargs)
+ self.__is_shut_down = _ImprovedEvent()
+ self.__serving = False
+
+ def serve_forever(self, poll_interval=0.5):
+ """
+ Handle one request at a time until shutdown.
+
+ Polls for shutdown every poll_interval seconds.
+ """
+ self.__serving = True
+ self.__is_shut_down.clear()
+ while self.__serving:
+ r, w, e = select.select([self], [], [], poll_interval)
+ if r:
+ self._handle_request_noblock()
+ self.__is_shut_down.set()
+
+ def shutdown(self):
+ """
+ Stops the serve_forever loop.
+
+ Blocks until the loop has finished. This must be called while
+ serve_forever() is running in another thread, or it will
+ deadlock.
+ """
+ self.__serving = False
+ if not self.__is_shut_down.wait(2):
+ raise RuntimeError(
+ "Failed to shutdown the live test server in 2 seconds. The "
+ "server might be stuck or generating a slow response.")
+
+ def handle_request(self):
+ """Handle one request, possibly blocking.
+ """
+ fd_sets = select.select([self], [], [], None)
+ if not fd_sets[0]:
+ return
+ self._handle_request_noblock()
+
+ def _handle_request_noblock(self):
+ """
+ Handle one request, without blocking.
+
+ I assume that select.select has returned that the socket is
+ readable before this function was called, so there should be
+ no risk of blocking in get_request().
+ """
+ try:
+ request, client_address = self.get_request()
+ except socket.error:
+ return
+ if self.verify_request(request, client_address):
+ try:
+ self.process_request(request, client_address)
+ except Exception:
+ self.handle_error(request, client_address)
+ self.close_request(request)
+
+
+class _MediaFilesHandler(StaticFilesHandler):
+ """
+ Handler for serving the media files. This is a private class that is
+ meant to be used solely as a convenience by LiveServerThread.
+ """
+
+ def get_base_dir(self):
+ return settings.MEDIA_ROOT
+
+ def get_base_url(self):
+ return settings.MEDIA_URL
+
+ def serve(self, request):
+ return serve(request, self.file_path(request.path),
+ document_root=self.get_base_dir())
+
+
+class LiveServerThread(threading.Thread):
+ """
+ Thread for running a live http server while the tests are running.
+ """
+
+ def __init__(self, address, port, connections_override=None):
+ self.address = address
+ self.port = port
+ self.is_ready = threading.Event()
+ self.error = None
+ self.connections_override = connections_override
+ super(LiveServerThread, self).__init__()
+
+ def run(self):
+ """
+ Sets up the live server and databases, and then loops over handling
+ http requests.
+ """
+ if self.connections_override:
+ from django.db import connections
+ # Override this thread's database connections with the ones
+ # provided by the main thread.
+ for alias, conn in self.connections_override.items():
+ connections[alias] = conn
+ try:
+ # Create the handler for serving static and media files
+ handler = StaticFilesHandler(_MediaFilesHandler(WSGIHandler()))
+ # Instantiate and start the WSGI server
+ self.httpd = StoppableWSGIServer(
+ (self.address, self.port), QuietWSGIRequestHandler)
+ self.httpd.set_app(handler)
+ self.is_ready.set()
+ self.httpd.serve_forever()
+ except Exception, e:
+ self.error = e
+ self.is_ready.set()
+
+ def join(self, timeout=None):
+ if hasattr(self, 'httpd'):
+ # Stop the WSGI server
+ self.httpd.shutdown()
+ self.httpd.server_close()
+ super(LiveServerThread, self).join(timeout)
+
+
+class LiveServerTestCase(TransactionTestCase):
+ """
+ Does basically the same as TransactionTestCase but also launches a live
+ http server in a separate thread so that the tests may use another testing
+ framework, such as Selenium for example, instead of the built-in dummy
+ client.
+ Note that it inherits from TransactionTestCase instead of TestCase because
+ the threads do not share the same transactions (unless if using in-memory
+ sqlite) and each thread needs to commit all their transactions so that the
+ other thread can see the changes.
+ """
+
+ @property
+ def live_server_url(self):
+ return 'http://%s' % self.__test_server_address
+
+ @classmethod
+ def setUpClass(cls):
+ connections_override = {}
+ for conn in connections.all():
+ # If using in-memory sqlite databases, pass the connections to
+ # the server thread.
+ if (conn.settings_dict['ENGINE'] == 'django.db.backends.sqlite3'
+ and conn.settings_dict['NAME'] == ':memory:'):
+ # Explicitly enable thread-shareability for this connection
+ conn.allow_thread_sharing = True
+ connections_override[conn.alias] = conn
+
+ # Launch the live server's thread
+ cls.__test_server_address = os.environ.get(
+ 'DJANGO_LIVE_TEST_SERVER_ADDRESS', 'localhost:8081')
+ try:
+ host, port = cls.__test_server_address.split(':')
+ except Exception:
+ raise ImproperlyConfigured('Invalid address ("%s") for live '
+ 'server.' % cls.__test_server_address)
+ cls.server_thread = LiveServerThread(
+ host, int(port), connections_override)
+ cls.server_thread.daemon = True
+ cls.server_thread.start()
+
+ # Wait for the live server to be ready
+ cls.server_thread.is_ready.wait()
+ if cls.server_thread.error:
+ raise cls.server_thread.error
+
+ super(LiveServerTestCase, cls).setUpClass()
+
+ @classmethod
+ def tearDownClass(cls):
+ # There may not be a 'server_thread' attribute if setUpClass() for some
+ # reasons has raised an exception.
+ if hasattr(cls, 'server_thread'):
+ # Terminate the live server's thread
+ cls.server_thread.join()
+ super(LiveServerTestCase, cls).tearDownClass()