Backported the Django 1.6 discover runner.
This commit is contained in:
69
README.rst
69
README.rst
@@ -1,12 +1,17 @@
|
||||
django-discover-runner
|
||||
======================
|
||||
|
||||
.. note::
|
||||
|
||||
This runner has been added to Django 1.6 as the default test runner.
|
||||
If you use Django 1.6 or above you don't need this app.
|
||||
|
||||
An alternative Django ``TEST_RUNNER`` which uses the unittest2_ test discovery
|
||||
from a base path specified in the settings, or any other module or package
|
||||
specified to the ``test`` management command -- including app tests.
|
||||
|
||||
If you just run ``./manage.py test``, it'll discover and run all tests
|
||||
underneath the ``TEST_DISCOVER_ROOT`` setting (a file system path). If you run
|
||||
underneath the current working directory. E.g. if you run
|
||||
``./manage.py test full.dotted.path.to.test_module``, it'll run the tests in
|
||||
that module (you can also pass multiple modules). If you give it a single
|
||||
dotted path to a package (like a Django app) like ``./manage.py test myapp``
|
||||
@@ -17,7 +22,8 @@ test discovery in all submodules of that package.
|
||||
|
||||
This code uses the default unittest2_ test discovery behavior, which
|
||||
only searches for tests in files named ``test*.py``. To override this
|
||||
see the ``TEST_DISCOVER_PATTERN`` setting below.
|
||||
see the ``TEST_DISCOVER_PATTERN`` setting or use the ``--pattern``
|
||||
option.
|
||||
|
||||
Why?
|
||||
----
|
||||
@@ -59,9 +65,8 @@ Install it with your favorite installer, e.g.::
|
||||
|
||||
pip install -U django-discover-runner
|
||||
|
||||
If you're using **Django < 1.3** you also have to install unittest2_::
|
||||
|
||||
pip install unittest2
|
||||
django-discover-runner requires at least Django 1.4 and also works on 1.5.x.
|
||||
Starting in Django 1.6 the discover runner is a built-in.
|
||||
|
||||
Setup
|
||||
-----
|
||||
@@ -75,16 +80,11 @@ Setup
|
||||
ability to override the discovery settings below when using the ``test``
|
||||
management command.
|
||||
|
||||
- ``TEST_DISCOVER_ROOT`` (optional) should be the root directory to discover
|
||||
tests within. You could make this the same as ``TEST_DISCOVER_TOP_LEVEL``
|
||||
if you want tests to be discovered anywhere in your project or app. The
|
||||
management command option is called ``--root``.
|
||||
|
||||
- ``TEST_DISCOVER_TOP_LEVEL`` (optional) should be the directory containing
|
||||
your top-level package(s); in other words, the directory that should be on
|
||||
``sys.path`` for your code to import. This is the directory containing
|
||||
``manage.py`` in the new Django 1.4 project layout. The management command
|
||||
option is called ``--top-level``.
|
||||
``sys.path`` for your code to import. This is for example the directory
|
||||
containing ``manage.py`` in the new Django 1.4 project layout.
|
||||
The management command option is called ``--top-level``.
|
||||
|
||||
- ``TEST_DISCOVER_PATTERN`` (optional) is the pattern to use when discovering
|
||||
tests and defaults to the unittest2_ standard ``test*.py``. The management
|
||||
@@ -105,8 +105,8 @@ the app package, e.g.::
|
||||
|
||||
django-admin.py test --settings=myapp.test_settings myapp
|
||||
|
||||
Django project (Django >= 1.4)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
Django project
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
If you want to test a project and want to store the project's tests outside
|
||||
the project main package (recommended), you can simply follow the app
|
||||
@@ -115,29 +115,41 @@ additional settings to tell the test runner to find the tests::
|
||||
|
||||
from os import path
|
||||
TEST_DISCOVER_TOP_LEVEL = path.dirname(path.dirname(__file__))
|
||||
TEST_DISCOVER_ROOT = path.join(TEST_DISCOVER_TOP_LEVEL, 'tests')
|
||||
|
||||
This would find all the tests within a top-level "tests" package. Running the
|
||||
tests is as easy as calling::
|
||||
|
||||
django-admin.py test --settings=mysite.test_settings
|
||||
django-admin.py test --settings=mysite.test_settings tests
|
||||
|
||||
Django project (Django < 1.4)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
Alternatively you can specify the ``--top-level-directory`` management
|
||||
command option.
|
||||
|
||||
For the old project style you can simply leave out one call to the
|
||||
``os.path.dirname`` function, since the old project directories were only
|
||||
one level deep::
|
||||
Multiple Django versions
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
from os import path
|
||||
TEST_DISCOVER_TOP_LEVEL = path.dirname(__file__)
|
||||
TEST_DISCOVER_ROOT = path.join(TEST_DISCOVER_TOP_LEVEL, 'tests')
|
||||
In case you want to test your app on older Django versions as well as
|
||||
Django >= 1.6 you can simply conditionally configure the test runner in your
|
||||
test settings, e.g.::
|
||||
|
||||
Other than that it's similar to the new project's style configuration.
|
||||
import django
|
||||
|
||||
if django.VERSION[:2] < (1, 6):
|
||||
TEST_RUNNER = 'discover_runner.DiscoverRunner'
|
||||
|
||||
Changelog
|
||||
---------
|
||||
|
||||
1.0 06/15/2013
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
* **GOOD NEWS!** This runner was added to Django 1.6 as the new default!
|
||||
This version backports that runner for Django 1.4.x and 1.5.x.
|
||||
|
||||
* Removed ``TEST_DISCOVER_ROOT`` setting in favor of unittest2's own way to
|
||||
figure out the root.
|
||||
|
||||
* Dropped support for Django 1.3.x.
|
||||
|
||||
0.4 04/12/2013
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
@@ -188,8 +200,9 @@ This test runner is a humble rip-off of Carl Meyer's ``DiscoveryRunner``
|
||||
which he published as a gist_ a while ago. All praise should be directed at
|
||||
him. Thanks, Carl!
|
||||
|
||||
This is also very much related to ticket `#17365`_ and is hopefully useful
|
||||
in replacing the default test runner in Django.
|
||||
This was also very much related to ticket `#17365`_ which eventually led
|
||||
to the replacement of the default test runner in Django. Thanks **again**,
|
||||
Carl!
|
||||
|
||||
.. _unittest2: http://pypi.python.org/pypi/unittest2
|
||||
.. _gist: https://gist.github.com/1450104
|
||||
|
||||
@@ -3,4 +3,4 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
__version__ = '0.4'
|
||||
__version__ = '1.0'
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
from optparse import make_option
|
||||
from django.core.management.commands.test import Command as TestCommand
|
||||
|
||||
from discover_runner import settings
|
||||
|
||||
|
||||
class Command(TestCommand):
|
||||
option_list = TestCommand.option_list + (
|
||||
make_option('-r', '--root',
|
||||
action='store', dest='test_discover_root',
|
||||
default=settings.TEST_DISCOVER_ROOT,
|
||||
help='Overrides the TEST_DISCOVER_ROOT setting.'),
|
||||
make_option('-t', '--top-level',
|
||||
action='store', dest='test_discover_top_level',
|
||||
default=settings.TEST_DISCOVER_TOP_LEVEL,
|
||||
help='Overrides the TEST_TOP_LEVEL setting.'),
|
||||
make_option('-p', '--pattern',
|
||||
action='store', dest='test_discover_pattern',
|
||||
default=settings.TEST_DISCOVER_PATTERN,
|
||||
help='Overrides the TEST_DISCOVER_PATTERN setting.'),
|
||||
)
|
||||
@@ -1,55 +1,298 @@
|
||||
import os
|
||||
from optparse import make_option
|
||||
|
||||
import django
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test import TestCase
|
||||
from django.test.simple import DjangoTestSuiteRunner, reorder_suite
|
||||
from django.utils.importlib import import_module
|
||||
from django.test.utils import setup_test_environment, teardown_test_environment
|
||||
from django.utils import unittest
|
||||
from django.utils.unittest import TestSuite, defaultTestLoader
|
||||
|
||||
from discover_runner.settings import (TEST_DISCOVER_ROOT,
|
||||
TEST_DISCOVER_TOP_LEVEL,
|
||||
TEST_DISCOVER_PATTERN)
|
||||
from .settings import TEST_DISCOVER_TOP_LEVEL, TEST_DISCOVER_PATTERN
|
||||
|
||||
try:
|
||||
from django.utils.unittest import defaultTestLoader
|
||||
except ImportError:
|
||||
try:
|
||||
from unittest2 import defaultTestLoader # noqa
|
||||
except ImportError:
|
||||
raise ImproperlyConfigured("Couldn't import unittest2 default "
|
||||
"test loader. Please use Django >= 1.3 "
|
||||
"or install the unittest2 library.")
|
||||
if not django.VERSION[:2] < (1, 6):
|
||||
raise ImproperlyConfigured("You are using Django >= 1.6 which comes "
|
||||
"with an own version of the discover test "
|
||||
"runner. Please remove 'discover_runner' "
|
||||
"from the INSTALLED_APPS and TEST_RUNNER "
|
||||
"settings and use the official runner instead.")
|
||||
|
||||
|
||||
class DiscoverRunner(DjangoTestSuiteRunner):
|
||||
class DiscoverRunner(object):
|
||||
"""
|
||||
A test suite runner that uses unittest2 test discovery.
|
||||
A Django test runner that uses unittest2 test discovery.
|
||||
"""
|
||||
|
||||
test_loader = defaultTestLoader
|
||||
reorder_by = (TestCase,)
|
||||
reorder_by = (TestCase, )
|
||||
option_list = (
|
||||
make_option('-t', '--top-level-directory',
|
||||
action='store', dest='top_level', default=TEST_DISCOVER_TOP_LEVEL,
|
||||
help='Top level of project for unittest discovery.'),
|
||||
make_option('-p', '--pattern', action='store', dest='pattern',
|
||||
default=TEST_DISCOVER_PATTERN,
|
||||
help='The test matching pattern. Defaults to test*.py.'),
|
||||
)
|
||||
|
||||
def __init__(self, discover_root=None, discover_top_level=None,
|
||||
discover_pattern=None, *args, **kwargs):
|
||||
super(DiscoverRunner, self).__init__(*args, **kwargs)
|
||||
self.discover_root = discover_root or TEST_DISCOVER_ROOT
|
||||
self.discover_top_level = discover_top_level or TEST_DISCOVER_TOP_LEVEL
|
||||
self.discover_pattern = discover_pattern or TEST_DISCOVER_PATTERN
|
||||
def __init__(self, pattern=None, top_level=None,
|
||||
verbosity=1, interactive=True, failfast=False,
|
||||
**kwargs):
|
||||
|
||||
def build_suite(self, test_labels, extra_tests=None):
|
||||
suite = None
|
||||
root = self.discover_root
|
||||
self.pattern = pattern
|
||||
self.top_level = top_level
|
||||
|
||||
if test_labels:
|
||||
suite = self.test_loader.loadTestsFromNames(test_labels)
|
||||
# if single named module has no tests, do discovery within it
|
||||
if not suite.countTestCases() and len(test_labels) == 1:
|
||||
suite = None
|
||||
root = import_module(test_labels[0]).__path__[0]
|
||||
self.verbosity = verbosity
|
||||
self.interactive = interactive
|
||||
self.failfast = failfast
|
||||
|
||||
if suite is None:
|
||||
suite = self.test_loader.discover(root,
|
||||
pattern=self.discover_pattern,
|
||||
top_level_dir=self.discover_top_level)
|
||||
def setup_test_environment(self, **kwargs):
|
||||
setup_test_environment()
|
||||
settings.DEBUG = False
|
||||
unittest.installHandler()
|
||||
|
||||
if extra_tests:
|
||||
for test in extra_tests:
|
||||
suite.addTest(test)
|
||||
def build_suite(self, test_labels=None, extra_tests=None, **kwargs):
|
||||
suite = TestSuite()
|
||||
test_labels = test_labels or ['.']
|
||||
extra_tests = extra_tests or []
|
||||
|
||||
discover_kwargs = {}
|
||||
if self.pattern is not None:
|
||||
discover_kwargs['pattern'] = self.pattern
|
||||
if self.top_level is not None:
|
||||
discover_kwargs['top_level_dir'] = self.top_level
|
||||
|
||||
for label in test_labels:
|
||||
kwargs = discover_kwargs.copy()
|
||||
tests = None
|
||||
|
||||
label_as_path = os.path.abspath(label)
|
||||
|
||||
# if a module, or "module.ClassName[.method_name]", just run those
|
||||
if not os.path.exists(label_as_path):
|
||||
tests = self.test_loader.loadTestsFromName(label)
|
||||
elif os.path.isdir(label_as_path) and not self.top_level:
|
||||
# Try to be a bit smarter than unittest about finding the
|
||||
# default top-level for a given directory path, to avoid
|
||||
# breaking relative imports. (Unittest's default is to set
|
||||
# top-level equal to the path, which means relative imports
|
||||
# will result in "Attempted relative import in non-package.").
|
||||
|
||||
# We'd be happy to skip this and require dotted module paths
|
||||
# (which don't cause this problem) instead of file paths (which
|
||||
# do), but in the case of a directory in the cwd, which would
|
||||
# be equally valid if considered as a top-level module or as a
|
||||
# directory path, unittest unfortunately prefers the latter.
|
||||
|
||||
top_level = label_as_path
|
||||
while True:
|
||||
init_py = os.path.join(top_level, '__init__.py')
|
||||
if os.path.exists(init_py):
|
||||
try_next = os.path.dirname(top_level)
|
||||
if try_next == top_level:
|
||||
# __init__.py all the way down? give up.
|
||||
break
|
||||
top_level = try_next
|
||||
continue
|
||||
break
|
||||
kwargs['top_level_dir'] = top_level
|
||||
|
||||
if not (tests and tests.countTestCases()):
|
||||
# if no tests found, it's probably a package; try discovery
|
||||
tests = self.test_loader.discover(start_dir=label, **kwargs)
|
||||
|
||||
# make unittest forget the top-level dir it calculated from the
|
||||
# run, to support running tests from two different top-levels.
|
||||
self.test_loader._top_level_dir = None
|
||||
|
||||
suite.addTests(tests)
|
||||
|
||||
for test in extra_tests:
|
||||
suite.addTest(test)
|
||||
|
||||
return reorder_suite(suite, self.reorder_by)
|
||||
|
||||
def setup_databases(self, **kwargs):
|
||||
return setup_databases(self.verbosity, self.interactive, **kwargs)
|
||||
|
||||
def run_suite(self, suite, **kwargs):
|
||||
return unittest.TextTestRunner(
|
||||
verbosity=self.verbosity,
|
||||
failfast=self.failfast,
|
||||
).run(suite)
|
||||
|
||||
def teardown_databases(self, old_config, **kwargs):
|
||||
"""
|
||||
Destroys all the non-mirror databases.
|
||||
"""
|
||||
old_names, mirrors = old_config
|
||||
for connection, old_name, destroy in old_names:
|
||||
if destroy:
|
||||
connection.creation.destroy_test_db(old_name, self.verbosity)
|
||||
|
||||
def teardown_test_environment(self, **kwargs):
|
||||
unittest.removeHandler()
|
||||
teardown_test_environment()
|
||||
|
||||
def suite_result(self, suite, result, **kwargs):
|
||||
return len(result.failures) + len(result.errors)
|
||||
|
||||
def run_tests(self, test_labels, extra_tests=None, **kwargs):
|
||||
"""
|
||||
Run the unit tests for all the test labels in the provided list.
|
||||
|
||||
Test labels should be dotted Python paths to test modules, test
|
||||
classes, or test methods.
|
||||
|
||||
A list of 'extra' tests may also be provided; these tests
|
||||
will be added to the test suite.
|
||||
|
||||
Returns the number of tests that failed.
|
||||
"""
|
||||
self.setup_test_environment()
|
||||
suite = self.build_suite(test_labels, extra_tests)
|
||||
old_config = self.setup_databases()
|
||||
result = self.run_suite(suite)
|
||||
self.teardown_databases(old_config)
|
||||
self.teardown_test_environment()
|
||||
return self.suite_result(suite, result)
|
||||
|
||||
|
||||
def dependency_ordered(test_databases, dependencies):
|
||||
"""
|
||||
Reorder test_databases into an order that honors the dependencies
|
||||
described in TEST_DEPENDENCIES.
|
||||
"""
|
||||
ordered_test_databases = []
|
||||
resolved_databases = set()
|
||||
|
||||
# Maps db signature to dependencies of all it's aliases
|
||||
dependencies_map = {}
|
||||
|
||||
# sanity check - no DB can depend on it's own alias
|
||||
for sig, (_, aliases) in test_databases:
|
||||
all_deps = set()
|
||||
for alias in aliases:
|
||||
all_deps.update(dependencies.get(alias, []))
|
||||
if not all_deps.isdisjoint(aliases):
|
||||
raise ImproperlyConfigured(
|
||||
"Circular dependency: databases %r depend on each other, "
|
||||
"but are aliases." % aliases)
|
||||
dependencies_map[sig] = all_deps
|
||||
|
||||
while test_databases:
|
||||
changed = False
|
||||
deferred = []
|
||||
|
||||
# Try to find a DB that has all it's dependencies met
|
||||
for signature, (db_name, aliases) in test_databases:
|
||||
if dependencies_map[signature].issubset(resolved_databases):
|
||||
resolved_databases.update(aliases)
|
||||
ordered_test_databases.append((signature, (db_name, aliases)))
|
||||
changed = True
|
||||
else:
|
||||
deferred.append((signature, (db_name, aliases)))
|
||||
|
||||
if not changed:
|
||||
raise ImproperlyConfigured(
|
||||
"Circular dependency in TEST_DEPENDENCIES")
|
||||
test_databases = deferred
|
||||
return ordered_test_databases
|
||||
|
||||
|
||||
def reorder_suite(suite, classes):
|
||||
"""
|
||||
Reorders a test suite by test type.
|
||||
|
||||
`classes` is a sequence of types
|
||||
|
||||
All tests of type classes[0] are placed first, then tests of type
|
||||
classes[1], etc. Tests with no match in classes are placed last.
|
||||
"""
|
||||
class_count = len(classes)
|
||||
bins = [unittest.TestSuite() for i in range(class_count + 1)]
|
||||
partition_suite(suite, classes, bins)
|
||||
for i in range(class_count):
|
||||
bins[0].addTests(bins[i + 1])
|
||||
return bins[0]
|
||||
|
||||
|
||||
def partition_suite(suite, classes, bins):
|
||||
"""
|
||||
Partitions a test suite by test type.
|
||||
|
||||
classes is a sequence of types
|
||||
bins is a sequence of TestSuites, one more than classes
|
||||
|
||||
Tests of type classes[i] are added to bins[i],
|
||||
tests with no match found in classes are place in bins[-1]
|
||||
"""
|
||||
for test in suite:
|
||||
if isinstance(test, unittest.TestSuite):
|
||||
partition_suite(test, classes, bins)
|
||||
else:
|
||||
for i in range(len(classes)):
|
||||
if isinstance(test, classes[i]):
|
||||
bins[i].addTest(test)
|
||||
break
|
||||
else:
|
||||
bins[-1].addTest(test)
|
||||
|
||||
|
||||
def setup_databases(verbosity, interactive, **kwargs):
|
||||
from django.db import connections, DEFAULT_DB_ALIAS
|
||||
|
||||
# First pass -- work out which databases actually need to be created,
|
||||
# and which ones are test mirrors or duplicate entries in DATABASES
|
||||
mirrored_aliases = {}
|
||||
test_databases = {}
|
||||
dependencies = {}
|
||||
for alias in connections:
|
||||
connection = connections[alias]
|
||||
if connection.settings_dict['TEST_MIRROR']:
|
||||
# If the database is marked as a test mirror, save
|
||||
# the alias.
|
||||
mirrored_aliases[alias] = (
|
||||
connection.settings_dict['TEST_MIRROR'])
|
||||
else:
|
||||
# Store a tuple with DB parameters that uniquely identify it.
|
||||
# If we have two aliases with the same values for that tuple,
|
||||
# we only need to create the test database once.
|
||||
item = test_databases.setdefault(
|
||||
connection.creation.test_db_signature(),
|
||||
(connection.settings_dict['NAME'], set())
|
||||
)
|
||||
item[1].add(alias)
|
||||
|
||||
if 'TEST_DEPENDENCIES' in connection.settings_dict:
|
||||
dependencies[alias] = (
|
||||
connection.settings_dict['TEST_DEPENDENCIES'])
|
||||
else:
|
||||
if alias != DEFAULT_DB_ALIAS:
|
||||
dependencies[alias] = connection.settings_dict.get(
|
||||
'TEST_DEPENDENCIES', [DEFAULT_DB_ALIAS])
|
||||
|
||||
# Second pass -- actually create the databases.
|
||||
old_names = []
|
||||
mirrors = []
|
||||
|
||||
for signature, (db_name, aliases) in dependency_ordered(
|
||||
test_databases.items(), dependencies):
|
||||
test_db_name = None
|
||||
# Actually create the database for the first connection
|
||||
|
||||
for alias in aliases:
|
||||
connection = connections[alias]
|
||||
old_names.append((connection, db_name, True))
|
||||
if test_db_name is None:
|
||||
test_db_name = connection.creation.create_test_db(
|
||||
verbosity, autoclobber=not interactive)
|
||||
else:
|
||||
connection.settings_dict['NAME'] = test_db_name
|
||||
|
||||
for alias, mirror_alias in mirrored_aliases.items():
|
||||
mirrors.append((alias, connections[alias].settings_dict['NAME']))
|
||||
connections[alias].settings_dict['NAME'] = (
|
||||
connections[mirror_alias].settings_dict['NAME'])
|
||||
|
||||
return old_names, mirrors
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.conf import settings
|
||||
|
||||
TEST_DISCOVER_ROOT = getattr(settings, 'TEST_DISCOVER_ROOT', '.')
|
||||
TEST_DISCOVER_TOP_LEVEL = getattr(settings, 'TEST_DISCOVER_TOP_LEVEL', None)
|
||||
TEST_DISCOVER_PATTERN = getattr(settings, 'TEST_DISCOVER_PATTERN', 'test*.py')
|
||||
|
||||
Reference in New Issue
Block a user