diff --git a/AUTHORS.rst b/AUTHORS.rst index 4b0e627..45cc98a 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -29,6 +29,7 @@ These non-maintainers have contributed code to a django-nose release: * Camilo Nova (`camilonova `_) * Carl Meyer (`carljm `_) * Conrado Buhrer (`conrado `_) +* Daniel Miller (`millerdev `_) * David Baumgold (`singingwolfboy `_) * David Cramer (`dcramer `_) * Dmitry Gladkov (`dgladkov `_) diff --git a/changelog.rst b/changelog.rst index b9fea48..1f3dfde 100644 --- a/changelog.rst +++ b/changelog.rst @@ -1,6 +1,10 @@ Changelog --------- +*Unreleased* +~~~~~~~~~~~~ +* Add support for custom database contexts. + 1.4.2 (2015-10-07) ~~~~~~~~~~~~~~~~~~ * Warn against using REUSE_DB=1 and FastFixtureTestCase in docs diff --git a/django_nose/plugin.py b/django_nose/plugin.py index 2e5e409..a0d3106 100644 --- a/django_nose/plugin.py +++ b/django_nose/plugin.py @@ -3,8 +3,11 @@ from __future__ import unicode_literals import sys +from itertools import groupby +from types import ModuleType from nose.plugins.base import Plugin +from nose.plugins.skip import SkipTest from nose.suite import ContextSuite from django.test.testcases import TransactionTestCase, TestCase @@ -82,13 +85,11 @@ def prepareTest(self, test): sys.stdout = self.sys_stdout self.runner.setup_test_environment() - self.old_names = self.runner.setup_databases() sys.stdout = sys_stdout def finalize(self, result): """Finalize test run by cleaning up databases and environment.""" - self.runner.teardown_databases(self.old_names) self.runner.teardown_test_environment() @@ -124,15 +125,25 @@ def add(self, test): self.remainder.append(test) -class TestReorderer(AlwaysOnPlugin): +class DatabaseSetUpPlugin(AlwaysOnPlugin): """Reorder tests for various reasons.""" name = 'django-nose-test-reorderer' + NON_DB_TESTS = 0 + FFTC_TESTS = 1 + CLEAN_TESTS = 2 + DIRTY_TESTS = 3 + + def __init__(self, runner): + """Initialize the plugin with the test runner.""" + super(DatabaseSetUpPlugin, self).__init__() + self.runner = runner + def options(self, parser, env): - """Add --with-fixture-bundling to options.""" - super(TestReorderer, self).options(parser, env) # pointless + """Add options.""" + super(DatabaseSetUpPlugin, self).options(parser, env) # pointless parser.add_option('--with-fixture-bundling', action='store_true', dest='with_fixture_bundling', @@ -140,14 +151,64 @@ def options(self, parser, env): help='Load a unique set of fixtures only once, even ' 'across test classes. ' '[NOSE_WITH_FIXTURE_BUNDLING]') + parser.add_option('--db', + dest='db_tests', + metavar='(skip|only)', + choices=['skip', 'only'], + default=env.get('NOSE_DB_TESTS'), + help='Skip or run only database tests. This option ' + 'accepts two values: "skip" database tests or ' + 'run "only" database tests. Both non-database ' + 'and database tests are run by default. ' + '[NOSE_DB_TESTS]') + parser.add_option('--db-test-context', + dest='db_test_context', + default=env.get('NOSE_DB_TEST_CONTEXT', + 'django_nose.plugin.DatabaseContext' + ), + help='A module path to a callable accepting two ' + 'arguments (tests, runner) and returning a ' + 'context object (with `setup` and `teardown` ' + 'methods) to be used while running database ' + 'tests. This is useful, for example, to ' + 'perform custom database setup/teardown. ' + 'Defaults to ' + 'django_nose.plugin.DatabaseContext. ' + '[NOSE_DB_TEST_CONTEXT]') + parser.add_option('--non-db-test-context', + dest='non_db_test_context', + default=env.get('NOSE_NON_DB_TEST_CONTEXT'), + help='A module path to a callable accepting two ' + 'arguments (tests, runner) and returning a ' + 'context object (with `setup` and `teardown` ' + 'methods) to be used while running non-' + 'database tests. This is useful, for example, ' + 'to enforce that non-database tests do not try ' + 'to access a database. The db-test-context ' + 'will be used for all tests if this option is ' + 'not specified. [NOSE_NON_DB_TEST_CONTEXT]') def configure(self, options, conf): """Configure plugin, reading the with_fixture_bundling option.""" - super(TestReorderer, self).configure(options, conf) + super(DatabaseSetUpPlugin, self).configure(options, conf) self.should_bundle = options.with_fixture_bundling + self.db_test_context_path = options.db_test_context + self.non_db_test_context_path = options.non_db_test_context + self.skip_non_db_tests = options.db_tests == 'only' + self.skip_db_tests = options.db_tests == 'skip' + + def _group_test_cases_by_type(self, test): + """Group test suite by test type. - def _put_transaction_test_cases_last(self, test): - """Reorder test suite so TransactionTestCase-based tests come last. + Test types: + + - Non-database tests (or ones that manage their own database connection + and cleanup) In simplest terms, this is any test that is not a + subclass of ``TransactionTestCase``. + - ``FastFixtureTestCase`` tests. + - Subclasses of ``django.test.TestCase`` and other database tests that + clean up after themselves. + - ``TransactionTestCase``-based tests, which often leave a mess. Django has a weird design decision wherein TransactionTestCase doesn't clean up after itself. Instead, it resets the DB to a clean state only @@ -171,35 +232,62 @@ def filthiness(test): because the odd behavior of TransactionTestCase is documented, so subclasses should by default be assumed to preserve it. + If the given test is a suite of tests with setup and/or teardown + routines in a "context" such as a module or package defining + fixture functions, then the entire suite will be assigned the value + of the filthiest test in the suite. + Thus, things will get these comparands (and run in this order): - * 1: TestCase subclasses. These clean up after themselves. - * 1: TransactionTestCase subclasses with - cleans_up_after_itself=True. These include - FastFixtureTestCases. If you're using the - FixtureBundlingPlugin, it will pull the FFTCs out, reorder - them, and run them first of all. - * 2: TransactionTestCase subclasses. These leave a mess. - * 2: Anything else (including doctests, I hope). These don't care - about the mess you left, because they don't hit the DB or, if - they do, are responsible for ensuring that it's clean (as per + * 0: Not a subclass of TransactionTestCase. These should not hit + the DB or, if they do, are responsible for ensuring that it's + clean (as per https://docs.djangoproject.com/en/dev/topics/testing/?from= - olddocs#writing-doctests) + olddocs#writing-doctests). + * 1: FastFixtureTestCase subclasses. If you're using the + FixtureBundlingPlugin, it will pull the FFTCs out, reorder + them, and run them before the following groups. + * 2: TestCase subclasses. These clean up after themselves. + * 2: TransactionTestCase subclasses with + cleans_up_after_itself=True. + * 3: TransactionTestCase subclasses. These leave a mess. """ test_class = test.context - if (is_subclass_at_all(test_class, TestCase) or - (is_subclass_at_all(test_class, TransactionTestCase) and - getattr(test_class, 'cleans_up_after_itself', False))): - return 1 - return 2 + if is_subclass_at_all(test_class, TransactionTestCase): + if (is_subclass_at_all(test_class, FastFixtureTestCase) and + self.should_bundle): + return self.FFTC_TESTS + if (is_subclass_at_all(test_class, TestCase) or + getattr(test_class, 'cleans_up_after_itself', False)): + return self.CLEAN_TESTS + return self.DIRTY_TESTS + if isinstance(test, ContextSuite) and \ + isinstance(test_class, ModuleType): + # Get value of filthiest test in module with setup/teardown. + # It should be safe to iterate over tests in a module because + # modules should not change state as tests are generated. + # However, other test types such as test generator functions + # may not be safe to iterate. + filth = set(filthiness(t) for t in test) + if len(filth) == 1: + return filth.pop() + for const in [ + self.DIRTY_TESTS, + self.CLEAN_TESTS, + self.FFTC_TESTS, # will be bundled in remainder bucket + ]: + if const in filth: + return const + return self.NON_DB_TESTS flattened = [] process_tests(test, flattened.append) flattened.sort(key=filthiness) - return ContextSuite(flattened) + return dict((key, list(group)) + for key, group in groupby(flattened, filthiness)) - def _bundle_fixtures(self, test): + def _bundle_fixtures(self, fftc_tests): """Reorder tests to minimize fixture loading. I reorder FastFixtureTestCases so ones using identical sets @@ -212,57 +300,152 @@ def _bundle_fixtures(self, test): nobody else, in practice, pays attention to the ``_fb`` advisory bits. We return those first, then any remaining tests in the order they were received. + + Add ``_fb_should_setup_fixtures`` and + ``_fb_should_teardown_fixtures`` attrs to each test class to advise + it whether to set up or tear down (respectively) the fixtures. + + Return a list of tests. """ - def suite_sorted_by_fixtures(suite): - """Flatten and sort a tree of Suites by fixture. + bucketer = Bucketer() + for test in fftc_tests: + bucketer.add(test) + + # Lay the bundles of common-fixture-having test classes end to end + # in a single list so we can make a test suite out of them: + tests = [] + for (key, fixture_bundle) in bucketer.buckets.items(): + fixtures, is_exempt = key + # Advise first and last test classes in each bundle to set up + # and tear down fixtures and the rest not to: + if fixtures and not is_exempt: + # Ones with fixtures are sure to be classes, which means + # they're sure to be ContextSuites with contexts. + + # First class with this set of fixtures sets up: + first = fixture_bundle[0].context + first._fb_should_setup_fixtures = True + + # Set all classes' 1..n should_setup to False: + for cls in fixture_bundle[1:]: + cls.context._fb_should_setup_fixtures = False + + # Last class tears down: + last = fixture_bundle[-1].context + last._fb_should_teardown_fixtures = True + + # Set all classes' 0..(n-1) should_teardown to False: + for cls in fixture_bundle[:-1]: + cls.context._fb_should_teardown_fixtures = False + + tests.extend(fixture_bundle) + tests.extend(bucketer.remainder) + + return tests + + def prepareTestRunner(self, runner): + """Get a runner that reorders tests before running them.""" + return _DatabaseSetupTestRunner(self, runner) + + def group_by_database_setup(self, test): + """Reorder the tests.""" + test_groups = self._group_test_cases_by_type(test) + suites = [] + + if self.skip_non_db_tests: + test_groups.pop(self.NON_DB_TESTS, None) + sys.__stdout__.write('skipped non-database tests\n') + elif self.non_db_test_context_path and self.NON_DB_TESTS in test_groups: + # setup context for non-database tests + non_db_tests = test_groups.pop(self.NON_DB_TESTS) + context = get_test_context( + self.non_db_test_context_path, non_db_tests, self.runner) + suites.append(ContextSuite(non_db_tests, context)) + + if self.should_bundle and self.FFTC_TESTS in test_groups: + fftc_tests = test_groups[self.FFTC_TESTS] + test_groups[self.FFTC_TESTS] = self._bundle_fixtures(fftc_tests) + + if self.skip_db_tests: + sys.__stdout__.write('skipped database tests (and setup)\n') + elif test_groups: + db_tests = [test_ + for key, group in sorted(test_groups.items()) + for test_ in group] + context = get_test_context( + self.db_test_context_path, db_tests, self.runner) + suites.append(ContextSuite(db_tests, context)) + + return suites[0] if len(suites) == 1 else ContextSuite(suites) + + +class _DatabaseSetupTestRunner(object): + + """A test runner that groups tests by database setup. + + This is a helper class that reorders tests for efficient database + setup. It modifies the test suite before any other plugins have a + chance to wrap it in the `prepareTest` hook. + """ - Add ``_fb_should_setup_fixtures`` and - ``_fb_should_teardown_fixtures`` attrs to each test class to advise - it whether to set up or tear down (respectively) the fixtures. + def __init__(self, plugin, real_runner): + self.plugin = plugin + self.runner = real_runner - Return a Suite. + def run(self, test): + test = self.plugin.group_by_database_setup(test) + return self.runner.run(test) - """ - bucketer = Bucketer() - process_tests(suite, bucketer.add) - # Lay the bundles of common-fixture-having test classes end to end - # in a single list so we can make a test suite out of them: - flattened = [] - for (key, fixture_bundle) in bucketer.buckets.items(): - fixtures, is_exempt = key - # Advise first and last test classes in each bundle to set up - # and tear down fixtures and the rest not to: - if fixtures and not is_exempt: - # Ones with fixtures are sure to be classes, which means - # they're sure to be ContextSuites with contexts. +def get_test_context(context_path, tests, runner): + """Make a test context. - # First class with this set of fixtures sets up: - first = fixture_bundle[0].context - first._fb_should_setup_fixtures = True + Lookup context constructor and call it with the given list of + tests and runner. - # Set all classes' 1..n should_setup to False: - for cls in fixture_bundle[1:]: - cls.context._fb_should_setup_fixtures = False + Returns the context + """ + from django_nose.runner import import_module + module_path, context_name = context_path.rsplit(".", 1) + module = import_module(module_path) + return getattr(module, context_name)(tests, runner) - # Last class tears down: - last = fixture_bundle[-1].context - last._fb_should_teardown_fixtures = True - # Set all classes' 0..(n-1) should_teardown to False: - for cls in fixture_bundle[:-1]: - cls.context._fb_should_teardown_fixtures = False +class DatabaseContext(object): - flattened.extend(fixture_bundle) - flattened.extend(bucketer.remainder) + """A context that performs standard Django database setup/teardown.""" - return ContextSuite(flattened) + def __init__(self, tests, runner): + """Initialize database context.""" + self.runner = runner - return suite_sorted_by_fixtures(test) + def setup(self): + """Setup database.""" + # temporarily restore sys.stdout in case of propmt to delete database + test_stdout = sys.stdout + sys.stdout = sys.__stdout__ + try: + self.old_names = self.runner.setup_databases() + finally: + sys.stdout = test_stdout + + def teardown(self): + """Tear down database.""" + self.runner.teardown_databases(self.old_names) - def prepareTest(self, test): - """Reorder the tests.""" - test = self._put_transaction_test_cases_last(test) - if self.should_bundle: - test = self._bundle_fixtures(test) - return test + +class NullContext(object): + + """A context that does nothing.""" + + def __init__(self, tests, runner): + """Initialize the context.""" + pass + + def setup(self): + """Do setup.""" + pass + + def teardown(self): + """Do teardown.""" + pass diff --git a/django_nose/runner.py b/django_nose/runner.py index e624db2..38745e4 100644 --- a/django_nose/runner.py +++ b/django_nose/runner.py @@ -42,7 +42,8 @@ import nose.core -from django_nose.plugin import DjangoSetUpPlugin, ResultPlugin, TestReorderer +from django_nose.plugin import DatabaseSetUpPlugin +from django_nose.plugin import DjangoSetUpPlugin, ResultPlugin from django_nose.utils import uses_mysql try: @@ -90,8 +91,7 @@ def _get_test_db_name(self): def _get_plugins_from_settings(): - plugins = (list(getattr(settings, 'NOSE_PLUGINS', [])) + - ['django_nose.plugin.TestReorderer']) + plugins = list(getattr(settings, 'NOSE_PLUGINS', [])) for plug_path in plugins: try: dot = plug_path.rindex('.') @@ -121,6 +121,7 @@ def _get_options(): cfg_files = nose.core.all_config_files() manager = nose.core.DefaultPluginManager() config = nose.core.Config(env=os.environ, files=cfg_files, plugins=manager) + config.plugins.addPlugin(DatabaseSetUpPlugin(None)) config.plugins.addPlugins(list(_get_plugins_from_settings())) options = config.getParser()._get_all_options() @@ -195,6 +196,7 @@ def add_arguments(cls, parser): manager = nose.core.DefaultPluginManager() config = nose.core.Config( env=os.environ, files=cfg_files, plugins=manager) + config.plugins.addPlugin(DatabaseSetUpPlugin(None)) config.plugins.addPlugins(list(_get_plugins_from_settings())) options = config.getParser()._get_all_options() @@ -285,9 +287,11 @@ class BasicNoseRunner(BaseRunner): def run_suite(self, nose_argv): """Run the test suite.""" result_plugin = ResultPlugin() - plugins_to_add = [DjangoSetUpPlugin(self), - result_plugin, - TestReorderer()] + plugins_to_add = [ + DjangoSetUpPlugin(self), + DatabaseSetUpPlugin(self), + result_plugin, + ] for plugin in _get_plugins_from_settings(): plugins_to_add.append(plugin) diff --git a/docs/usage.rst b/docs/usage.rst index ffbc1ca..a5a32db 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -150,6 +150,54 @@ course of loading tests. For example, if the tests that need it are in sure its DB table gets created. +Custom Database Contexts +------------------------ + +Django-nose can be instructed to use custom contexts for database and non- +database tests. What this means is that you can write a "context" class to do +custom setup and/or teardown for database tests (i.e., tests that extend +``TransactionTestCase`` or one of its subclasses) and/or non-database tests +(i.e., tests that do not extend ``TransactionTestCase``). This might mean +creating a new database that Django does not support or patching Django's +database machinery to assert that tests that should not access a database fail +if they try to do so. + +Custom contexts can be specified with the following options: + + NOSE_PLUGINS = [ + '--db-test-context=django_nose.plugin.DatabaseContext', + '--non-db-test-context=django_nose.plugin.NullContext', + ] + +While this example uses contexts that come with django-nose, you can of +course specify paths for your own custom context classes. A custom context +class might look something like this: + + class CustomContext(django_nose.plugin.DatabaseContext): + + """Setup/teardown custom database and standard Django databases.""" + + def setup(self): + """Setup database.""" + + # do custom database setup here + + super(CustomContext, self).setup() + + def teardown(self): + """Tear down database.""" + + # do custom database teardown here + + super(CustomContext, self).teardown() + +For each type of context, ``context.setup()`` is called once before the first +test of its type is run, and ``context.teardown()`` is called once after the +last test of its type is run. The database context will be used for all tests, +including "non-database" tests, unless ``--non-db-test-context=...`` is +specified. + + Assertions ----------