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
This commit is contained in:
Julien Phalip 2011-12-29 20:22:13 +00:00
parent a82204fa9a
commit 0bf2d33770
3 changed files with 102 additions and 37 deletions

View File

@ -9,6 +9,7 @@ from xml.dom.minidom import parseString, Node
import select import select
import socket import socket
import threading import threading
import errno
from django.conf import settings from django.conf import settings
from django.contrib.staticfiles.handlers import StaticFilesHandler 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.handlers.wsgi import WSGIHandler
from django.core.management import call_command from django.core.management import call_command
from django.core.signals import request_started 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.urlresolvers import clear_url_caches
from django.core.validators import EMPTY_VALUES from django.core.validators import EMPTY_VALUES
from django.db import (transaction, connection, connections, DEFAULT_DB_ALIAS, 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. Thread for running a live http server while the tests are running.
""" """
def __init__(self, address, port, connections_override=None): def __init__(self, host, possible_ports, connections_override=None):
self.address = address self.host = host
self.port = port self.port = None
self.possible_ports = possible_ports
self.is_ready = threading.Event() self.is_ready = threading.Event()
self.error = None self.error = None
self.connections_override = connections_override self.connections_override = connections_override
@ -899,9 +902,33 @@ class LiveServerThread(threading.Thread):
try: try:
# Create the handler for serving static and media files # Create the handler for serving static and media files
handler = StaticFilesHandler(_MediaFilesHandler(WSGIHandler())) handler = StaticFilesHandler(_MediaFilesHandler(WSGIHandler()))
# Instantiate and start the WSGI server
# 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.httpd = StoppableWSGIServer(
(self.address, self.port), QuietWSGIRequestHandler) (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.httpd.set_app(handler)
self.is_ready.set() self.is_ready.set()
self.httpd.serve_forever() self.httpd.serve_forever()
@ -931,7 +958,8 @@ class LiveServerTestCase(TransactionTestCase):
@property @property
def live_server_url(self): 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 @classmethod
def setUpClass(cls): def setUpClass(cls):
@ -946,15 +974,31 @@ class LiveServerTestCase(TransactionTestCase):
connections_override[conn.alias] = conn connections_override[conn.alias] = conn
# Launch the live server's thread # 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') '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: 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: except Exception:
raise ImproperlyConfigured('Invalid address ("%s") for live ' raise ImproperlyConfigured('Invalid address ("%s") for live '
'server.' % cls.__test_server_address) 'server.' % specified_address)
cls.server_thread = LiveServerThread( cls.server_thread = LiveServerThread(
host, int(port), connections_override) host, possible_ports, connections_override)
cls.server_thread.daemon = True cls.server_thread.daemon = True
cls.server_thread.start() cls.server_thread.start()

View File

@ -1772,15 +1772,38 @@ simulate a real user's actions.
By default the live server's address is `'localhost:8081'` and the full URL 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 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 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 already taken) then you may pass a different one to the :djadmin:`test` command
the :djadminopt:`--liveserver` option, for example: via the :djadminopt:`--liveserver` option, for example:
.. code-block:: bash .. code-block:: bash
./manage.py test --liveserver=localhost:8082 ./manage.py test --liveserver=localhost:8082
Another way of changing the default server address is by setting the 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 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 test. First of all, you need to install the `selenium package`_ into your

View File

@ -101,10 +101,7 @@ class LiveServerBase(LiveServerTestCase):
super(LiveServerBase, cls).tearDownClass() super(LiveServerBase, cls).tearDownClass()
def urlopen(self, url): def urlopen(self, url):
server_address = os.environ.get( return urllib2.urlopen(self.live_server_url + url)
'DJANGO_LIVE_TEST_SERVER_ADDRESS', 'localhost:8081')
base = 'http://%s' % server_address
return urllib2.urlopen(base + url)
class LiveServerAddress(LiveServerBase): class LiveServerAddress(LiveServerBase):
@ -120,31 +117,23 @@ class LiveServerAddress(LiveServerBase):
old_address = os.environ.get('DJANGO_LIVE_TEST_SERVER_ADDRESS') old_address = os.environ.get('DJANGO_LIVE_TEST_SERVER_ADDRESS')
# Just the host is not accepted # Just the host is not accepted
os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] = 'localhost' cls.raises_exception('localhost', ImproperlyConfigured)
try:
super(LiveServerAddress, cls).setUpClass()
raise Exception("The line above should have raised an exception")
except ImproperlyConfigured:
pass
# The host must be valid # The host must be valid
os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] = 'blahblahblah:8081' cls.raises_exception('blahblahblah:8081', WSGIServerException)
try:
super(LiveServerAddress, cls).setUpClass() # The list of ports must be in a valid format
raise Exception("The line above should have raised an exception") cls.raises_exception('localhost:8081,', ImproperlyConfigured)
except WSGIServerException: cls.raises_exception('localhost:8081,blah', ImproperlyConfigured)
pass 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 # If contrib.staticfiles isn't configured properly, the exception
# should bubble up to the main thread. # should bubble up to the main thread.
old_STATIC_URL = TEST_SETTINGS['STATIC_URL'] old_STATIC_URL = TEST_SETTINGS['STATIC_URL']
TEST_SETTINGS['STATIC_URL'] = None TEST_SETTINGS['STATIC_URL'] = None
os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] = 'localhost:8081' cls.raises_exception('localhost:8081', ImproperlyConfigured)
try:
super(LiveServerAddress, cls).setUpClass()
raise Exception("The line above should have raised an exception")
except ImproperlyConfigured:
pass
TEST_SETTINGS['STATIC_URL'] = old_STATIC_URL TEST_SETTINGS['STATIC_URL'] = old_STATIC_URL
# Restore original environment variable # Restore original environment variable
@ -153,6 +142,15 @@ class LiveServerAddress(LiveServerBase):
else: else:
del os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] 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): def test_test_test(self):
# Intentionally empty method so that the test is picked up by the # Intentionally empty method so that the test is picked up by the
# test runner and the overriden setUpClass() method is executed. # test runner and the overriden setUpClass() method is executed.