diff options
| author | Julien Phalip <jphalip@gmail.com> | 2011-12-22 08:33:58 +0000 |
|---|---|---|
| committer | Julien Phalip <jphalip@gmail.com> | 2011-12-22 08:33:58 +0000 |
| commit | 2f02a05ffb45be68b4164b4785ff1826833150a3 (patch) | |
| tree | d51f7454aeb97a5c35b3045d5d5413691aaf1d00 /django | |
| parent | 45e3dff5ac697f16829697bc2a899eaeac8986ea (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.py | 52 | ||||
| -rw-r--r-- | django/core/management/commands/test.py | 37 | ||||
| -rw-r--r-- | django/test/__init__.py | 3 | ||||
| -rw-r--r-- | django/test/testcases.py | 245 |
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() |
