Commits

Anonymous committed 1ee979d

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.

Comments (0)

Files changed (3)

django/test/testcases.py

 import select
 import socket
 import threading
+import errno
 
 from django.conf import settings
 from django.contrib.staticfiles.handlers import StaticFilesHandler
 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,
     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
         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()
 
     @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):
                 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()
 

docs/topics/testing.txt

 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<topics-testing-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

tests/regressiontests/servers/tests.py

         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):
         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
         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.