Skip to content

Commit 74b5074

Browse files
smithdc1felixxm
authored andcommitted
Fixed #34210 -- Added unittest's durations option to the test runner.
1 parent 27b399d commit 74b5074

File tree

8 files changed

+110
-5
lines changed

8 files changed

+110
-5
lines changed

django/test/runner.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from django.test.utils import teardown_databases as _teardown_databases
3030
from django.test.utils import teardown_test_environment
3131
from django.utils.datastructures import OrderedSet
32+
from django.utils.version import PY312
3233

3334
try:
3435
import ipdb as pdb
@@ -285,6 +286,10 @@ def stopTest(self, test):
285286
super().stopTest(test)
286287
self.events.append(("stopTest", self.test_index))
287288

289+
def addDuration(self, test, elapsed):
290+
super().addDuration(test, elapsed)
291+
self.events.append(("addDuration", self.test_index, elapsed))
292+
288293
def addError(self, test, err):
289294
self.check_picklable(test, err)
290295
self.events.append(("addError", self.test_index, err))
@@ -655,6 +660,7 @@ def __init__(
655660
timing=False,
656661
shuffle=False,
657662
logger=None,
663+
durations=None,
658664
**kwargs,
659665
):
660666
self.pattern = pattern
@@ -692,6 +698,7 @@ def __init__(
692698
self.shuffle = shuffle
693699
self._shuffler = None
694700
self.logger = logger
701+
self.durations = durations
695702

696703
@classmethod
697704
def add_arguments(cls, parser):
@@ -791,6 +798,15 @@ def add_arguments(cls, parser):
791798
"unittest -k option."
792799
),
793800
)
801+
if PY312:
802+
parser.add_argument(
803+
"--durations",
804+
dest="durations",
805+
type=int,
806+
default=None,
807+
metavar="N",
808+
help="Show the N slowest test cases (N=0 for all).",
809+
)
794810

795811
@property
796812
def shuffle_seed(self):
@@ -953,12 +969,15 @@ def get_resultclass(self):
953969
return PDBDebugResult
954970

955971
def get_test_runner_kwargs(self):
956-
return {
972+
kwargs = {
957973
"failfast": self.failfast,
958974
"resultclass": self.get_resultclass(),
959975
"verbosity": self.verbosity,
960976
"buffer": self.buffer,
961977
}
978+
if PY312:
979+
kwargs["durations"] = self.durations
980+
return kwargs
962981

963982
def run_checks(self, databases):
964983
# Checks are run after database creation since some checks require

docs/ref/django-admin.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1559,6 +1559,16 @@ tests, which allows it to print a traceback if the interpreter crashes. Pass
15591559

15601560
Outputs timings, including database setup and total run time.
15611561

1562+
.. django-admin-option:: --durations N
1563+
1564+
.. versionadded:: 5.0
1565+
1566+
Shows the N slowest test cases (N=0 for all).
1567+
1568+
.. admonition:: Python 3.12 and later
1569+
1570+
This feature is only available for Python 3.12 and later.
1571+
15621572
``testserver``
15631573
--------------
15641574

docs/releases/5.0.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,9 @@ Tests
476476

477477
* :class:`~django.test.AsyncClient` now supports the ``follow`` parameter.
478478

479+
* The new :option:`test --durations` option allows showing the duration of the
480+
slowest tests on Python 3.12+.
481+
479482
URLs
480483
~~~~
481484

docs/topics/testing/advanced.txt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,7 @@ behavior. This class defines the ``run_tests()`` entry point, plus a
533533
selection of other methods that are used by ``run_tests()`` to set up, execute
534534
and tear down the test suite.
535535

536-
.. class:: DiscoverRunner(pattern='test*.py', top_level=None, verbosity=1, interactive=True, failfast=False, keepdb=False, reverse=False, debug_mode=False, debug_sql=False, parallel=0, tags=None, exclude_tags=None, test_name_patterns=None, pdb=False, buffer=False, enable_faulthandler=True, timing=True, shuffle=False, logger=None, **kwargs)
536+
.. class:: DiscoverRunner(pattern='test*.py', top_level=None, verbosity=1, interactive=True, failfast=False, keepdb=False, reverse=False, debug_mode=False, debug_sql=False, parallel=0, tags=None, exclude_tags=None, test_name_patterns=None, pdb=False, buffer=False, enable_faulthandler=True, timing=True, shuffle=False, logger=None, durations=None, **kwargs)
537537

538538
``DiscoverRunner`` will search for tests in any file matching ``pattern``.
539539

@@ -613,6 +613,10 @@ and tear down the test suite.
613613
the console. The logger object will respect its logging level rather than
614614
the ``verbosity``.
615615

616+
``durations`` will show a list of the N slowest test cases. Setting this
617+
option to ``0`` will result in the duration for all tests being shown.
618+
Requires Python 3.12+.
619+
616620
Django may, from time to time, extend the capabilities of the test runner
617621
by adding new arguments. The ``**kwargs`` declaration allows for this
618622
expansion. If you subclass ``DiscoverRunner`` or write your own test
@@ -623,6 +627,10 @@ and tear down the test suite.
623627
custom arguments by calling ``parser.add_argument()`` inside the method, so
624628
that the :djadmin:`test` command will be able to use those arguments.
625629

630+
.. versionadded:: 5.0
631+
632+
The ``durations`` argument was added.
633+
626634
Attributes
627635
~~~~~~~~~~
628636

tests/runtests.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
RemovedInDjango60Warning,
3434
)
3535
from django.utils.log import DEFAULT_LOGGING
36+
from django.utils.version import PY312
3637

3738
try:
3839
import MySQLdb
@@ -380,6 +381,7 @@ def django_tests(
380381
buffer,
381382
timing,
382383
shuffle,
384+
durations=None,
383385
):
384386
if parallel in {0, "auto"}:
385387
max_parallel = get_max_test_processes()
@@ -425,6 +427,7 @@ def django_tests(
425427
buffer=buffer,
426428
timing=timing,
427429
shuffle=shuffle,
430+
durations=durations,
428431
)
429432
failures = test_runner.run_tests(test_labels)
430433
teardown_run_tests(state)
@@ -688,6 +691,15 @@ def paired_tests(paired_test, options, test_labels, start_at, start_after):
688691
"Same as unittest -k option. Can be used multiple times."
689692
),
690693
)
694+
if PY312:
695+
parser.add_argument(
696+
"--durations",
697+
dest="durations",
698+
type=int,
699+
default=None,
700+
metavar="N",
701+
help="Show the N slowest test cases (N=0 for all).",
702+
)
691703

692704
options = parser.parse_args()
693705

@@ -785,6 +797,7 @@ def paired_tests(paired_test, options, test_labels, start_at, start_after):
785797
options.buffer,
786798
options.timing,
787799
options.shuffle,
800+
getattr(options, "durations", None),
788801
)
789802
time_keeper.print_results()
790803
if failures:

tests/test_runner/test_discover_runner.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
captured_stderr,
1717
captured_stdout,
1818
)
19+
from django.utils.version import PY312
1920

2021

2122
@contextmanager
@@ -765,6 +766,22 @@ def test_suite_result_with_failure(self):
765766
failures = runner.suite_result(suite, result)
766767
self.assertEqual(failures, expected_failures)
767768

769+
@unittest.skipUnless(PY312, "unittest --durations option requires Python 3.12")
770+
def test_durations(self):
771+
with captured_stderr() as stderr, captured_stdout():
772+
runner = DiscoverRunner(durations=10)
773+
suite = runner.build_suite(["test_runner_apps.simple.tests.SimpleCase1"])
774+
runner.run_suite(suite)
775+
self.assertIn("Slowest test durations", stderr.getvalue())
776+
777+
@unittest.skipUnless(PY312, "unittest --durations option requires Python 3.12")
778+
def test_durations_debug_sql(self):
779+
with captured_stderr() as stderr, captured_stdout():
780+
runner = DiscoverRunner(durations=10, debug_sql=True)
781+
suite = runner.build_suite(["test_runner_apps.simple.SimpleCase1"])
782+
runner.run_suite(suite)
783+
self.assertIn("Slowest test durations", stderr.getvalue())
784+
768785

769786
class DiscoverRunnerGetDatabasesTests(SimpleTestCase):
770787
runner = DiscoverRunner(verbosity=2)

tests/test_runner/test_parallel.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from django.test import SimpleTestCase
66
from django.test.runner import RemoteTestResult
7-
from django.utils.version import PY311
7+
from django.utils.version import PY311, PY312
88

99
try:
1010
import tblib.pickling_support
@@ -118,7 +118,11 @@ def test_add_failing_subtests(self):
118118
subtest_test.run(result=result)
119119

120120
events = result.events
121-
self.assertEqual(len(events), 4)
121+
# addDurations added in Python 3.12.
122+
if PY312:
123+
self.assertEqual(len(events), 5)
124+
else:
125+
self.assertEqual(len(events), 4)
122126
self.assertIs(result.wasSuccessful(), False)
123127

124128
event = events[1]
@@ -133,3 +137,9 @@ def test_add_failing_subtests(self):
133137

134138
event = events[2]
135139
self.assertEqual(repr(event[3][1]), "AssertionError('2 != 1')")
140+
141+
@unittest.skipUnless(PY312, "unittest --durations option requires Python 3.12")
142+
def test_add_duration(self):
143+
result = RemoteTestResult()
144+
result.addDuration(None, 2.3)
145+
self.assertEqual(result.collectedDurations, [("None", 2.3)])

tests/test_runner/tests.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from django.conf import settings
1515
from django.core.exceptions import ImproperlyConfigured
1616
from django.core.management import call_command
17-
from django.core.management.base import SystemCheckError
17+
from django.core.management.base import CommandError, SystemCheckError
1818
from django.test import SimpleTestCase, TransactionTestCase, skipUnlessDBFeature
1919
from django.test.runner import (
2020
DiscoverRunner,
@@ -31,6 +31,7 @@
3131
get_unique_databases_and_mirrors,
3232
iter_test_cases,
3333
)
34+
from django.utils.version import PY312
3435

3536
from .models import B, Person, Through
3637

@@ -451,6 +452,8 @@ class MockTestRunner:
451452
def __init__(self, *args, **kwargs):
452453
if parallel := kwargs.get("parallel"):
453454
sys.stderr.write(f"parallel={parallel}")
455+
if durations := kwargs.get("durations"):
456+
sys.stderr.write(f"durations={durations}")
454457

455458

456459
MockTestRunner.run_tests = mock.Mock(return_value=[])
@@ -475,6 +478,28 @@ def test_time_recorded(self):
475478
)
476479
self.assertIn("Total run took", stderr.getvalue())
477480

481+
@unittest.skipUnless(PY312, "unittest --durations option requires Python 3.12")
482+
def test_durations(self):
483+
with captured_stderr() as stderr:
484+
call_command(
485+
"test",
486+
"--durations=10",
487+
"sites",
488+
testrunner="test_runner.tests.MockTestRunner",
489+
)
490+
self.assertIn("durations=10", stderr.getvalue())
491+
492+
@unittest.skipIf(PY312, "unittest --durations option requires Python 3.12")
493+
def test_durations_lt_py312(self):
494+
msg = "Error: unrecognized arguments: --durations=10"
495+
with self.assertRaises(CommandError, msg=msg):
496+
call_command(
497+
"test",
498+
"--durations=10",
499+
"sites",
500+
testrunner="test_runner.tests.MockTestRunner",
501+
)
502+
478503

479504
# Isolate from the real environment.
480505
@mock.patch.dict(os.environ, {}, clear=True)

0 commit comments

Comments
 (0)