diff --git a/django/test/runner.py b/django/test/runner.py index b6cd1ad487..a9787d9b2f 100644 --- a/django/test/runner.py +++ b/django/test/runner.py @@ -1,3 +1,4 @@ +import argparse import ctypes import faulthandler import hashlib @@ -335,16 +336,20 @@ class RemoteTestRunner: return result -def default_test_processes(): - """Default number of test processes when using the --parallel option.""" +def parallel_type(value): + """Parse value passed to the --parallel option.""" # The current implementation of the parallel test runner requires # multiprocessing to start subprocesses with fork(). if multiprocessing.get_start_method() != 'fork': return 1 - try: - return int(os.environ['DJANGO_TEST_PROCESSES']) - except KeyError: + if value == 'auto': return multiprocessing.cpu_count() + try: + return int(value) + except ValueError: + raise argparse.ArgumentTypeError( + f"{value!r} is not an integer or the string 'auto'" + ) _worker_id = 0 @@ -611,10 +616,17 @@ class DiscoverRunner: '-d', '--debug-sql', action='store_true', help='Prints logged SQL queries on failure.', ) + try: + default_parallel = int(os.environ['DJANGO_TEST_PROCESSES']) + except KeyError: + default_parallel = 0 parser.add_argument( - '--parallel', nargs='?', default=1, type=int, - const=default_test_processes(), metavar='N', - help='Run tests using up to N parallel processes.', + '--parallel', nargs='?', const='auto', default=default_parallel, + type=parallel_type, metavar='N', + help=( + 'Run tests using up to N parallel processes. Use the value ' + '"auto" to run one test process for each processor core.' + ), ) parser.add_argument( '--tag', action='append', dest='tags', diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index 4af510731f..0a346b5af7 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -1467,10 +1467,12 @@ Enables :ref:`SQL logging ` for failing tests. If Runs tests in separate parallel processes. Since modern processors have multiple cores, this allows running tests significantly faster. -By default ``--parallel`` runs one process per core according to -:func:`multiprocessing.cpu_count()`. You can adjust the number of processes -either by providing it as the option's value, e.g. ``--parallel=4``, or by -setting the :envvar:`DJANGO_TEST_PROCESSES` environment variable. +Using ``--parallel`` without a value, or with the value ``auto``, runs one test +process per core according to :func:`multiprocessing.cpu_count()`. You can +override this by passing the desired number of processes, e.g. +``--parallel 4``. You can also enable ``--parallel`` without passing the flag +by setting the :envvar:`DJANGO_TEST_PROCESSES` environment variable to the +desired number of processes. Django distributes test cases — :class:`unittest.TestCase` subclasses — to subprocesses. If there are fewer test cases than configured processes, Django @@ -1511,6 +1513,10 @@ don't. in order to exchange them between processes. See :ref:`python:pickle-picklable` for details. +.. versionchanged:: 4.0 + + Support for the value ``auto`` was added. + .. option:: --tag TAGS Runs only tests :ref:`marked with the specified tags `. diff --git a/docs/releases/4.0.txt b/docs/releases/4.0.txt index 825a669e00..3af4726bc9 100644 --- a/docs/releases/4.0.txt +++ b/docs/releases/4.0.txt @@ -350,6 +350,9 @@ Tests * Django test runner now supports a :option:`--shuffle ` option to execute tests in a random order. +* The :option:`test --parallel` option now supports the value ``auto`` to run + one test process for each processor core. + URLs ~~~~ diff --git a/tests/runtests.py b/tests/runtests.py index 648ac27e05..dfbc70818c 100755 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -23,7 +23,7 @@ else: from django.conf import settings from django.db import connection, connections from django.test import TestCase, TransactionTestCase - from django.test.runner import default_test_processes + from django.test.runner import parallel_type from django.test.selenium import SeleniumTestCaseBase from django.test.utils import NullTimeKeeper, TimeKeeper, get_runner from django.utils.deprecation import ( @@ -329,7 +329,7 @@ def actual_test_processes(parallel): if parallel == 0: # This doesn't work before django.setup() on some databases. if all(conn.features.can_clone_databases for conn in connections.all()): - return default_test_processes() + return parallel_type('auto') else: return 1 else: @@ -354,11 +354,12 @@ def django_tests(verbosity, interactive, failfast, keepdb, reverse, test_labels, debug_sql, parallel, tags, exclude_tags, test_name_patterns, start_at, start_after, pdb, buffer, timing, shuffle): + actual_parallel = actual_test_processes(parallel) + if verbosity >= 1: msg = "Testing against Django installed in '%s'" % os.path.dirname(django.__file__) - max_parallel = default_test_processes() if parallel == 0 else parallel - if max_parallel > 1: - msg += " with up to %d processes" % max_parallel + if actual_parallel > 1: + msg += " with up to %d processes" % actual_parallel print(msg) test_labels, state = setup_run_tests(verbosity, start_at, start_after, test_labels) @@ -373,7 +374,7 @@ def django_tests(verbosity, interactive, failfast, keepdb, reverse, keepdb=keepdb, reverse=reverse, debug_sql=debug_sql, - parallel=actual_test_processes(parallel), + parallel=actual_parallel, tags=tags, exclude_tags=exclude_tags, test_name_patterns=test_name_patterns, @@ -562,10 +563,18 @@ if __name__ == "__main__": '--debug-sql', action='store_true', help='Turn on the SQL query logger within tests.', ) + try: + default_parallel = int(os.environ['DJANGO_TEST_PROCESSES']) + except KeyError: + # actual_test_processes() converts this to "auto" later on. + default_parallel = 0 parser.add_argument( - '--parallel', nargs='?', default=0, type=int, - const=default_test_processes(), metavar='N', - help='Run tests using up to N parallel processes.', + '--parallel', nargs='?', const='auto', default=default_parallel, + type=parallel_type, metavar='N', + help=( + 'Run tests using up to N parallel processes. Use the value "auto" ' + 'to run one test process for each processor core.' + ), ) parser.add_argument( '--tag', dest='tags', action='append', diff --git a/tests/test_runner/test_discover_runner.py b/tests/test_runner/test_discover_runner.py index 327a6625ae..f62f157149 100644 --- a/tests/test_runner/test_discover_runner.py +++ b/tests/test_runner/test_discover_runner.py @@ -50,16 +50,31 @@ class DiscoverRunnerParallelArgumentTests(SimpleTestCase): def test_parallel_default(self, *mocked_objects): result = self.get_parser().parse_args([]) - self.assertEqual(result.parallel, 1) + self.assertEqual(result.parallel, 0) def test_parallel_flag(self, *mocked_objects): result = self.get_parser().parse_args(['--parallel']) self.assertEqual(result.parallel, 12) + def test_parallel_auto(self, *mocked_objects): + result = self.get_parser().parse_args(['--parallel', 'auto']) + self.assertEqual(result.parallel, 12) + def test_parallel_count(self, *mocked_objects): result = self.get_parser().parse_args(['--parallel', '17']) self.assertEqual(result.parallel, 17) + def test_parallel_invalid(self, *mocked_objects): + with self.assertRaises(SystemExit), captured_stderr() as stderr: + self.get_parser().parse_args(['--parallel', 'unaccepted']) + msg = "argument --parallel: 'unaccepted' is not an integer or the string 'auto'" + self.assertIn(msg, stderr.getvalue()) + + @mock.patch.dict(os.environ, {'DJANGO_TEST_PROCESSES': '7'}) + def test_parallel_env_var(self, *mocked_objects): + result = self.get_parser().parse_args([]) + self.assertEqual(result.parallel, 7) + @mock.patch.dict(os.environ, {'DJANGO_TEST_PROCESSES': 'typo'}) def test_parallel_env_var_non_int(self, *mocked_objects): with self.assertRaises(ValueError):