From 0bf2d337701a41d9fc42c6cac608e18f989a9866 Mon Sep 17 00:00:00 2001 From: Julien Phalip Date: Thu, 29 Dec 2011 20:22:13 +0000 Subject: [PATCH] Added the ability to specify multiple ports available for the `LiveServerTestCase` WSGI server. This allows multiple processes to run the tests simultaneously and is particularly useful in a continuous integration context. Many thanks to Aymeric Augustin for the suggestions and feedback. git-svn-id: http://code.djangoproject.com/svn/django/trunk@17289 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/test/testcases.py | 68 +++++++++++++++++++++----- docs/topics/testing.txt | 29 +++++++++-- tests/regressiontests/servers/tests.py | 42 ++++++++-------- 3 files changed, 102 insertions(+), 37 deletions(-) diff --git a/django/test/testcases.py b/django/test/testcases.py index d77eb39bde..4de4750aee 100644 --- a/django/test/testcases.py +++ b/django/test/testcases.py @@ -9,6 +9,7 @@ from xml.dom.minidom import parseString, Node import select import socket import threading +import errno from django.conf import settings from django.contrib.staticfiles.handlers import StaticFilesHandler @@ -17,7 +18,8 @@ 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.servers.basehttp import (WSGIRequestHandler, WSGIServer, + WSGIServerException) 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, @@ -877,9 +879,10 @@ 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 + def __init__(self, host, possible_ports, connections_override=None): + self.host = host + self.port = None + self.possible_ports = possible_ports self.is_ready = threading.Event() self.error = None self.connections_override = connections_override @@ -899,9 +902,33 @@ class LiveServerThread(threading.Thread): 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) + + # Go through the list of possible ports, hoping that we can find + # one that is free to use for the WSGI server. + for index, port in enumerate(self.possible_ports): + try: + self.httpd = StoppableWSGIServer( + (self.host, port), QuietWSGIRequestHandler) + except WSGIServerException, e: + if sys.version_info < (2, 6): + error_code = e.args[0].args[0] + else: + error_code = e.args[0].errno + if (index + 1 < len(self.possible_ports) and + error_code == errno.EADDRINUSE): + # This port is already in use, so we go on and try with + # the next one in the list. + continue + else: + # Either none of the given ports are free or the error + # is something else than "Address already in use". So + # we let that error bubble up to the main thread. + raise + else: + # A free port was found. + self.port = port + break + self.httpd.set_app(handler) self.is_ready.set() self.httpd.serve_forever() @@ -931,7 +958,8 @@ class LiveServerTestCase(TransactionTestCase): @property def live_server_url(self): - return 'http://%s' % self.__test_server_address + return 'http://%s:%s' % ( + self.server_thread.host, self.server_thread.port) @classmethod def setUpClass(cls): @@ -946,15 +974,31 @@ class LiveServerTestCase(TransactionTestCase): connections_override[conn.alias] = conn # Launch the live server's thread - cls.__test_server_address = os.environ.get( + specified_address = os.environ.get( 'DJANGO_LIVE_TEST_SERVER_ADDRESS', 'localhost:8081') + + # The specified ports may be of the form '8000-8010,8080,9200-9300' + # i.e. a comma-separated list of ports or ranges of ports, so we break + # it down into a detailed list of all possible ports. + possible_ports = [] try: - host, port = cls.__test_server_address.split(':') + host, port_ranges = specified_address.split(':') + for port_range in port_ranges.split(','): + # A port range can be of either form: '8000' or '8000-8010'. + extremes = map(int, port_range.split('-')) + assert len(extremes) in [1, 2] + if len(extremes) == 1: + # Port range of the form '8000' + possible_ports.append(extremes[0]) + else: + # Port range of the form '8000-8010' + for port in range(extremes[0], extremes[1] + 1): + possible_ports.append(port) except Exception: raise ImproperlyConfigured('Invalid address ("%s") for live ' - 'server.' % cls.__test_server_address) + 'server.' % specified_address) cls.server_thread = LiveServerThread( - host, int(port), connections_override) + host, possible_ports, connections_override) cls.server_thread.daemon = True cls.server_thread.start() diff --git a/docs/topics/testing.txt b/docs/topics/testing.txt index a39fc315be..3c25577400 100644 --- a/docs/topics/testing.txt +++ b/docs/topics/testing.txt @@ -1772,15 +1772,38 @@ simulate a real user's actions. By default the live server's address is `'localhost:8081'` and the full URL can be accessed during the tests with ``self.live_server_url``. If you'd like to change the default address (in the case, for example, where the 8081 port is -already taken) you may pass a different one to the :djadmin:`test` command via -the :djadminopt:`--liveserver` option, for example: +already taken) then you may pass a different one to the :djadmin:`test` command +via the :djadminopt:`--liveserver` option, for example: .. code-block:: bash ./manage.py test --liveserver=localhost:8082 Another way of changing the default server address is by setting the -`DJANGO_LIVE_TEST_SERVER_ADDRESS` environment variable. +`DJANGO_LIVE_TEST_SERVER_ADDRESS` environment variable somewhere in your +code (for example in a :ref:`custom test runner` +if you're using one): + +.. code-block:: python + + import os + os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] = 'localhost:8082' + +In the case where the tests are run by multiple processes in parallel (for +example in the context of several simultaneous `continuous integration`_ +builds), the processes will compete for the same address and therefore your +tests might randomly fail with an "Address already in use" error. To avoid this +problem, you can pass a comma-separated list of ports or ranges of ports (at +least as many as the number of potential parallel processes), for example: + +.. code-block:: bash + + ./manage.py test --liveserver=localhost:8082,8090-8100,9000-9200,7041 + +Then, during the execution of the tests, each new live test server will try +every specified port until it finds one that is free and takes it. + +.. _continuous integration: http://en.wikipedia.org/wiki/Continuous_integration To demonstrate how to use ``LiveServerTestCase``, let's write a simple Selenium test. First of all, you need to install the `selenium package`_ into your diff --git a/tests/regressiontests/servers/tests.py b/tests/regressiontests/servers/tests.py index 8cf1b78600..d237c83c65 100644 --- a/tests/regressiontests/servers/tests.py +++ b/tests/regressiontests/servers/tests.py @@ -101,10 +101,7 @@ class LiveServerBase(LiveServerTestCase): super(LiveServerBase, cls).tearDownClass() def urlopen(self, url): - server_address = os.environ.get( - 'DJANGO_LIVE_TEST_SERVER_ADDRESS', 'localhost:8081') - base = 'http://%s' % server_address - return urllib2.urlopen(base + url) + return urllib2.urlopen(self.live_server_url + url) class LiveServerAddress(LiveServerBase): @@ -120,31 +117,23 @@ class LiveServerAddress(LiveServerBase): old_address = os.environ.get('DJANGO_LIVE_TEST_SERVER_ADDRESS') # Just the host is not accepted - os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] = 'localhost' - try: - super(LiveServerAddress, cls).setUpClass() - raise Exception("The line above should have raised an exception") - except ImproperlyConfigured: - pass + cls.raises_exception('localhost', ImproperlyConfigured) # The host must be valid - os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] = 'blahblahblah:8081' - try: - super(LiveServerAddress, cls).setUpClass() - raise Exception("The line above should have raised an exception") - except WSGIServerException: - pass + cls.raises_exception('blahblahblah:8081', WSGIServerException) + + # The list of ports must be in a valid format + cls.raises_exception('localhost:8081,', ImproperlyConfigured) + cls.raises_exception('localhost:8081,blah', ImproperlyConfigured) + cls.raises_exception('localhost:8081-', ImproperlyConfigured) + cls.raises_exception('localhost:8081-blah', ImproperlyConfigured) + cls.raises_exception('localhost:8081-8082-8083', ImproperlyConfigured) # If contrib.staticfiles isn't configured properly, the exception # should bubble up to the main thread. old_STATIC_URL = TEST_SETTINGS['STATIC_URL'] TEST_SETTINGS['STATIC_URL'] = None - os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] = 'localhost:8081' - try: - super(LiveServerAddress, cls).setUpClass() - raise Exception("The line above should have raised an exception") - except ImproperlyConfigured: - pass + cls.raises_exception('localhost:8081', ImproperlyConfigured) TEST_SETTINGS['STATIC_URL'] = old_STATIC_URL # Restore original environment variable @@ -153,6 +142,15 @@ class LiveServerAddress(LiveServerBase): else: del os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] + @classmethod + def raises_exception(cls, address, exception): + os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] = address + try: + super(LiveServerAddress, cls).setUpClass() + raise Exception("The line above should have raised an exception") + except exception: + pass + def test_test_test(self): # Intentionally empty method so that the test is picked up by the # test runner and the overriden setUpClass() method is executed.