Refs #31811 -- Added optional timing outputs to the test runner.

This commit is contained in:
Ahmad A. Hussein 2020-07-22 17:37:52 +02:00 committed by Carlton Gibson
parent 21768a99f4
commit 61a0ba43cf
9 changed files with 144 additions and 38 deletions

View File

@ -3,7 +3,7 @@ import sys
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.core.management.utils import get_command_line_option from django.core.management.utils import get_command_line_option
from django.test.utils import get_runner from django.test.utils import NullTimeKeeper, TimeKeeper, get_runner
class Command(BaseCommand): class Command(BaseCommand):
@ -49,8 +49,10 @@ class Command(BaseCommand):
def handle(self, *test_labels, **options): def handle(self, *test_labels, **options):
TestRunner = get_runner(settings, options['testrunner']) TestRunner = get_runner(settings, options['testrunner'])
time_keeper = TimeKeeper() if options.get('timing', False) else NullTimeKeeper()
test_runner = TestRunner(**options) test_runner = TestRunner(**options)
failures = test_runner.run_tests(test_labels) with time_keeper.timed('Total run'):
failures = test_runner.run_tests(test_labels)
time_keeper.print_results()
if failures: if failures:
sys.exit(1) sys.exit(1)

View File

@ -16,8 +16,9 @@ from django.core.management import call_command
from django.db import connections from django.db import connections
from django.test import SimpleTestCase, TestCase from django.test import SimpleTestCase, TestCase
from django.test.utils import ( from django.test.utils import (
setup_databases as _setup_databases, setup_test_environment, NullTimeKeeper, TimeKeeper, setup_databases as _setup_databases,
teardown_databases as _teardown_databases, teardown_test_environment, setup_test_environment, teardown_databases as _teardown_databases,
teardown_test_environment,
) )
from django.utils.datastructures import OrderedSet from django.utils.datastructures import OrderedSet
from django.utils.version import PY37 from django.utils.version import PY37
@ -437,7 +438,8 @@ class DiscoverRunner:
interactive=True, failfast=False, keepdb=False, interactive=True, failfast=False, keepdb=False,
reverse=False, debug_mode=False, debug_sql=False, parallel=0, reverse=False, debug_mode=False, debug_sql=False, parallel=0,
tags=None, exclude_tags=None, test_name_patterns=None, tags=None, exclude_tags=None, test_name_patterns=None,
pdb=False, buffer=False, enable_faulthandler=True, **kwargs): pdb=False, buffer=False, enable_faulthandler=True,
timing=False, **kwargs):
self.pattern = pattern self.pattern = pattern
self.top_level = top_level self.top_level = top_level
@ -466,6 +468,7 @@ class DiscoverRunner:
'--parallel=1 to use it.' '--parallel=1 to use it.'
) )
self.test_name_patterns = None self.test_name_patterns = None
self.time_keeper = TimeKeeper() if timing else NullTimeKeeper()
if test_name_patterns: if test_name_patterns:
# unittest does not export the _convert_select_pattern function # unittest does not export the _convert_select_pattern function
# that converts command-line arguments to patterns. # that converts command-line arguments to patterns.
@ -525,6 +528,12 @@ class DiscoverRunner:
'--no-faulthandler', action='store_false', dest='enable_faulthandler', '--no-faulthandler', action='store_false', dest='enable_faulthandler',
help='Disables the Python faulthandler module during tests.', help='Disables the Python faulthandler module during tests.',
) )
parser.add_argument(
'--timing', action='store_true',
help=(
'Output timings, including database set up and total run time.'
),
)
if PY37: if PY37:
parser.add_argument( parser.add_argument(
'-k', action='append', dest='test_name_patterns', '-k', action='append', dest='test_name_patterns',
@ -624,8 +633,8 @@ class DiscoverRunner:
def setup_databases(self, **kwargs): def setup_databases(self, **kwargs):
return _setup_databases( return _setup_databases(
self.verbosity, self.interactive, self.keepdb, self.debug_sql, self.verbosity, self.interactive, time_keeper=self.time_keeper, keepdb=self.keepdb,
self.parallel, **kwargs debug_sql=self.debug_sql, parallel=self.parallel, **kwargs
) )
def get_resultclass(self): def get_resultclass(self):
@ -704,7 +713,8 @@ class DiscoverRunner:
self.setup_test_environment() self.setup_test_environment()
suite = self.build_suite(test_labels, extra_tests) suite = self.build_suite(test_labels, extra_tests)
databases = self.get_databases(suite) databases = self.get_databases(suite)
old_config = self.setup_databases(aliases=databases) with self.time_keeper.timed('Total database setup'):
old_config = self.setup_databases(aliases=databases)
run_failed = False run_failed = False
try: try:
self.run_checks(databases) self.run_checks(databases)
@ -714,13 +724,15 @@ class DiscoverRunner:
raise raise
finally: finally:
try: try:
self.teardown_databases(old_config) with self.time_keeper.timed('Total database teardown'):
self.teardown_databases(old_config)
self.teardown_test_environment() self.teardown_test_environment()
except Exception: except Exception:
# Silence teardown exceptions if an exception was raised during # Silence teardown exceptions if an exception was raised during
# runs to avoid shadowing it. # runs to avoid shadowing it.
if not run_failed: if not run_failed:
raise raise
self.time_keeper.print_results()
return self.suite_result(suite, result) return self.suite_result(suite, result)

View File

@ -1,5 +1,7 @@
import asyncio import asyncio
import collections
import logging import logging
import os
import re import re
import sys import sys
import time import time
@ -152,7 +154,8 @@ def teardown_test_environment():
del mail.outbox del mail.outbox
def setup_databases(verbosity, interactive, keepdb=False, debug_sql=False, parallel=0, aliases=None, **kwargs): def setup_databases(verbosity, interactive, *, time_keeper, keepdb=False, debug_sql=False, parallel=0,
aliases=None):
"""Create the test databases.""" """Create the test databases."""
test_databases, mirrored_aliases = get_unique_databases_and_mirrors(aliases) test_databases, mirrored_aliases = get_unique_databases_and_mirrors(aliases)
@ -167,19 +170,21 @@ def setup_databases(verbosity, interactive, keepdb=False, debug_sql=False, paral
# Actually create the database for the first connection # Actually create the database for the first connection
if first_alias is None: if first_alias is None:
first_alias = alias first_alias = alias
connection.creation.create_test_db( with time_keeper.timed(" Creating '%s'" % alias):
verbosity=verbosity, connection.creation.create_test_db(
autoclobber=not interactive, verbosity=verbosity,
keepdb=keepdb, autoclobber=not interactive,
serialize=connection.settings_dict['TEST'].get('SERIALIZE', True), keepdb=keepdb,
) serialize=connection.settings_dict['TEST'].get('SERIALIZE', True),
)
if parallel > 1: if parallel > 1:
for index in range(parallel): for index in range(parallel):
connection.creation.clone_test_db( with time_keeper.timed(" Cloning '%s'" % alias):
suffix=str(index + 1), connection.creation.clone_test_db(
verbosity=verbosity, suffix=str(index + 1),
keepdb=keepdb, verbosity=verbosity,
) keepdb=keepdb,
)
# Configure all other connections as mirrors of the first one # Configure all other connections as mirrors of the first one
else: else:
connections[alias].creation.set_as_test_mirror(connections[first_alias].settings_dict) connections[alias].creation.set_as_test_mirror(connections[first_alias].settings_dict)
@ -841,6 +846,36 @@ class isolate_apps(TestContextDecorator):
setattr(Options, 'default_apps', self.old_apps) setattr(Options, 'default_apps', self.old_apps)
class TimeKeeper:
def __init__(self):
self.records = collections.defaultdict(list)
@contextmanager
def timed(self, name):
self.records[name]
start_time = time.perf_counter()
try:
yield
finally:
end_time = time.perf_counter() - start_time
self.records[name].append(end_time)
def print_results(self):
for name, end_times in self.records.items():
for record_time in end_times:
record = '%s took %.3fs' % (name, record_time)
sys.stderr.write(record + os.linesep)
class NullTimeKeeper:
@contextmanager
def timed(self, name):
yield
def print_results(self):
pass
def tag(*tags): def tag(*tags):
"""Decorator to add tags to a test class or method.""" """Decorator to add tags to a test class or method."""
def decorator(obj): def decorator(obj):

View File

@ -1523,6 +1523,12 @@ Django automatically calls :func:`faulthandler.enable()` when starting the
tests, which allows it to print a traceback if the interpreter crashes. Pass tests, which allows it to print a traceback if the interpreter crashes. Pass
``--no-faulthandler`` to disable this behavior. ``--no-faulthandler`` to disable this behavior.
.. django-admin-option:: --timing
.. versionadded:: 3.2
Outputs timings, including including database setup and total run time.
``testserver`` ``testserver``
-------------- --------------

View File

@ -330,6 +330,11 @@ Tests
:py:mod:`faulthandler` by default. This can be disabled by using the :py:mod:`faulthandler` by default. This can be disabled by using the
:option:`test --no-faulthandler` option. :option:`test --no-faulthandler` option.
* :class:`~django.test.runner.DiscoverRunner` and the
:djadmin:`test` management command can now track timings, including database
setup and total run time. This can be enabled by using the :option:`test
--timing` option.
* :class:`~django.test.Client` now preserves the request query string when * :class:`~django.test.Client` now preserves the request query string when
following 307 and 308 redirects. following 307 and 308 redirects.
@ -454,6 +459,9 @@ Miscellaneous
* Instantiating an abstract model now raises ``TypeError``. * Instantiating an abstract model now raises ``TypeError``.
* Keyword arguments to :func:`~django.test.utils.setup_databases` are now
keyword-only.
.. _deprecated-features-3.2: .. _deprecated-features-3.2:
Features deprecated in 3.2 Features deprecated in 3.2

View File

@ -510,7 +510,7 @@ behavior. This class defines the ``run_tests()`` entry point, plus a
selection of other methods that are used to by ``run_tests()`` to set up, selection of other methods that are used to by ``run_tests()`` to set up,
execute and tear down the test suite. execute and tear down the test suite.
.. class:: DiscoverRunner(pattern='test*.py', top_level=None, verbosity=1, interactive=True, failfast=False, keepdb=False, reverse=False, debug_mode=False, debug_sql=False, test_name_patterns=None, pdb=False, buffer=False, enable_faulthandler=True, **kwargs) .. class:: DiscoverRunner(pattern='test*.py', top_level=None, verbosity=1, interactive=True, failfast=False, keepdb=False, reverse=False, debug_mode=False, debug_sql=False, test_name_patterns=None, pdb=False, buffer=False, enable_faulthandler=True, timing=True, **kwargs)
``DiscoverRunner`` will search for tests in any file matching ``pattern``. ``DiscoverRunner`` will search for tests in any file matching ``pattern``.
@ -560,6 +560,9 @@ execute and tear down the test suite.
If ``enable_faulthandler`` is ``True``, :py:mod:`faulthandler` will be If ``enable_faulthandler`` is ``True``, :py:mod:`faulthandler` will be
enabled. enabled.
If ``timing`` is ``True``, test timings, including database setup and total
run time, will be shown.
Django may, from time to time, extend the capabilities of the test runner Django may, from time to time, extend the capabilities of the test runner
by adding new arguments. The ``**kwargs`` declaration allows for this by adding new arguments. The ``**kwargs`` declaration allows for this
expansion. If you subclass ``DiscoverRunner`` or write your own test expansion. If you subclass ``DiscoverRunner`` or write your own test
@ -576,7 +579,7 @@ execute and tear down the test suite.
.. versionadded:: 3.2 .. versionadded:: 3.2
The ``enable_faulthandler`` argument was added. The ``enable_faulthandler`` and ``timing`` arguments were added.
Attributes Attributes
~~~~~~~~~~ ~~~~~~~~~~
@ -659,7 +662,7 @@ Methods
Returns a ``TestSuite`` instance ready to be run. Returns a ``TestSuite`` instance ready to be run.
.. method:: DiscoverRunner.setup_databases(**kwargs) .. method:: DiscoverRunner.setup_databases(verbosity, interactive, **kwargs)
Creates the test databases by calling Creates the test databases by calling
:func:`~django.test.utils.setup_databases`. :func:`~django.test.utils.setup_databases`.
@ -723,7 +726,7 @@ utility methods in the ``django.test.utils`` module.
Performs global post-test teardown, such as removing instrumentation from Performs global post-test teardown, such as removing instrumentation from
the template system and restoring normal email services. the template system and restoring normal email services.
.. function:: setup_databases(verbosity, interactive, keepdb=False, debug_sql=False, parallel=0, aliases=None, **kwargs) .. function:: setup_databases(verbosity, interactive, *, time_keeper, keepdb=False, debug_sql=False, parallel=0, aliases=None, **kwargs)
Creates the test databases. Creates the test databases.
@ -735,6 +738,11 @@ utility methods in the ``django.test.utils`` module.
databases should be setup for. If it's not provided, it defaults to all of databases should be setup for. If it's not provided, it defaults to all of
:setting:`DATABASES` aliases. :setting:`DATABASES` aliases.
.. versionchanged:: 3.2
The ``time_keeper`` kwarg was added, and all kwargs were made
keyword-only.
.. function:: teardown_databases(old_config, parallel=0, keepdb=False) .. function:: teardown_databases(old_config, parallel=0, keepdb=False)
Destroys the test databases, restoring pre-test conditions. Destroys the test databases, restoring pre-test conditions.

View File

@ -23,7 +23,7 @@ else:
from django.test import TestCase, TransactionTestCase from django.test import TestCase, TransactionTestCase
from django.test.runner import default_test_processes from django.test.runner import default_test_processes
from django.test.selenium import SeleniumTestCaseBase from django.test.selenium import SeleniumTestCaseBase
from django.test.utils import get_runner from django.test.utils import NullTimeKeeper, TimeKeeper, get_runner
from django.utils.deprecation import ( from django.utils.deprecation import (
RemovedInDjango40Warning, RemovedInDjango41Warning, RemovedInDjango40Warning, RemovedInDjango41Warning,
) )
@ -287,7 +287,8 @@ class ActionSelenium(argparse.Action):
def django_tests(verbosity, interactive, failfast, keepdb, reverse, def django_tests(verbosity, interactive, failfast, keepdb, reverse,
test_labels, debug_sql, parallel, tags, exclude_tags, test_labels, debug_sql, parallel, tags, exclude_tags,
test_name_patterns, start_at, start_after, pdb, buffer): test_name_patterns, start_at, start_after, pdb, buffer,
timing):
state = setup(verbosity, test_labels, parallel, start_at, start_after) state = setup(verbosity, test_labels, parallel, start_at, start_after)
extra_tests = [] extra_tests = []
@ -309,6 +310,7 @@ def django_tests(verbosity, interactive, failfast, keepdb, reverse,
test_name_patterns=test_name_patterns, test_name_patterns=test_name_patterns,
pdb=pdb, pdb=pdb,
buffer=buffer, buffer=buffer,
timing=timing,
) )
failures = test_runner.run_tests( failures = test_runner.run_tests(
test_labels or get_installed(), test_labels or get_installed(),
@ -508,6 +510,10 @@ if __name__ == "__main__":
'-b', '--buffer', action='store_true', '-b', '--buffer', action='store_true',
help='Discard output of passing tests.', help='Discard output of passing tests.',
) )
parser.add_argument(
'--timing', action='store_true',
help='Output timings, including database set up and total run time.',
)
if PY37: if PY37:
parser.add_argument( parser.add_argument(
'-k', dest='test_name_patterns', action='append', '-k', dest='test_name_patterns', action='append',
@ -568,13 +574,17 @@ if __name__ == "__main__":
options.start_at, options.start_after, options.start_at, options.start_after,
) )
else: else:
failures = django_tests( time_keeper = TimeKeeper() if options.timing else NullTimeKeeper()
options.verbosity, options.interactive, options.failfast, with time_keeper.timed('Total run'):
options.keepdb, options.reverse, options.modules, failures = django_tests(
options.debug_sql, options.parallel, options.tags, options.verbosity, options.interactive, options.failfast,
options.exclude_tags, options.keepdb, options.reverse, options.modules,
getattr(options, 'test_name_patterns', None), options.debug_sql, options.parallel, options.tags,
options.start_at, options.start_after, options.pdb, options.buffer, options.exclude_tags,
) getattr(options, 'test_name_patterns', None),
options.start_at, options.start_after, options.pdb, options.buffer,
options.timing,
)
time_keeper.print_results()
if failures: if failures:
sys.exit(1) sys.exit(1)

View File

@ -8,7 +8,9 @@ from unittest import (
from django.db import connections from django.db import connections
from django.test import SimpleTestCase from django.test import SimpleTestCase
from django.test.runner import DiscoverRunner from django.test.runner import DiscoverRunner
from django.test.utils import captured_stderr, captured_stdout from django.test.utils import (
NullTimeKeeper, TimeKeeper, captured_stderr, captured_stdout,
)
from django.utils.version import PY37 from django.utils.version import PY37
@ -324,6 +326,24 @@ class DiscoverRunnerTests(SimpleTestCase):
DiscoverRunner(enable_faulthandler=False) DiscoverRunner(enable_faulthandler=False)
mocked_enable.assert_not_called() mocked_enable.assert_not_called()
def test_timings_not_captured(self):
runner = DiscoverRunner(timing=False)
with captured_stderr() as stderr:
with runner.time_keeper.timed('test'):
pass
runner.time_keeper.print_results()
self.assertTrue(isinstance(runner.time_keeper, NullTimeKeeper))
self.assertNotIn('test', stderr.getvalue())
def test_timings_captured(self):
runner = DiscoverRunner(timing=True)
with captured_stderr() as stderr:
with runner.time_keeper.timed('test'):
pass
runner.time_keeper.print_results()
self.assertTrue(isinstance(runner.time_keeper, TimeKeeper))
self.assertIn('test', stderr.getvalue())
class DiscoverRunnerGetDatabasesTests(SimpleTestCase): class DiscoverRunnerGetDatabasesTests(SimpleTestCase):
runner = DiscoverRunner(verbosity=2) runner = DiscoverRunner(verbosity=2)

View File

@ -14,7 +14,7 @@ from django.core.management.base import SystemCheckError
from django.test import TransactionTestCase, skipUnlessDBFeature, testcases from django.test import TransactionTestCase, skipUnlessDBFeature, testcases
from django.test.runner import DiscoverRunner from django.test.runner import DiscoverRunner
from django.test.testcases import connections_support_transactions from django.test.testcases import connections_support_transactions
from django.test.utils import dependency_ordered from django.test.utils import captured_stderr, dependency_ordered
from .models import B, Person, Through from .models import B, Person, Through
@ -148,6 +148,11 @@ class ManageCommandTests(unittest.TestCase):
with self.assertRaises(AttributeError): with self.assertRaises(AttributeError):
call_command('test', 'sites', testrunner='test_runner.NonexistentRunner') call_command('test', 'sites', testrunner='test_runner.NonexistentRunner')
def test_time_recorded(self):
with captured_stderr() as stderr:
call_command('test', '--timing', 'sites', testrunner='test_runner.tests.MockTestRunner')
self.assertIn('Total run took', stderr.getvalue())
class CustomTestRunnerOptionsSettingsTests(AdminScriptTestCase): class CustomTestRunnerOptionsSettingsTests(AdminScriptTestCase):
""" """