Backported the Django 1.6 discover runner.

This commit is contained in:
Jannis Leidel
2013-06-15 15:06:34 +02:00
parent 96f0bf887a
commit 747e48f8bd
7 changed files with 324 additions and 90 deletions

View File

@@ -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

View File

@@ -3,4 +3,4 @@ try:
except ImportError:
pass
__version__ = '0.4'
__version__ = '1.0'

View File

@@ -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.'),
)

View File

@@ -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

View File

@@ -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')