Skip to content

Commit 5b48dc6

Browse files
jonashaagpitrou
authored andcommitted
bpo-32071: Add unittest -k option (#4496)
* bpo-32071: Add unittest -k option
1 parent 8d9bb11 commit 5b48dc6

File tree

6 files changed

+126
-14
lines changed

6 files changed

+126
-14
lines changed

Doc/library/unittest.rst

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,22 @@ Command-line options
219219

220220
Stop the test run on the first error or failure.
221221

222+
.. cmdoption:: -k
223+
224+
Only run test methods and classes that match the pattern or substring.
225+
This option may be used multiple times, in which case all test cases that
226+
match of the given patterns are included.
227+
228+
Patterns that contain a wildcard character (``*``) are matched against the
229+
test name using :meth:`fnmatch.fnmatchcase`; otherwise simple case-sensitive
230+
substring matching is used.
231+
232+
Patterns are matched against the fully qualified test method name as
233+
imported by the test loader.
234+
235+
For example, ``-k foo`` matches ``foo_tests.SomeTest.test_something``,
236+
``bar_tests.SomeTest.test_foo``, but not ``bar_tests.FooTest.test_something``.
237+
222238
.. cmdoption:: --locals
223239

224240
Show local variables in tracebacks.
@@ -229,6 +245,9 @@ Command-line options
229245
.. versionadded:: 3.5
230246
The command-line option ``--locals``.
231247

248+
.. versionadded:: 3.7
249+
The command-line option ``-k``.
250+
232251
The command line can also be used for test discovery, for running all of the
233252
tests in a project or just a subset.
234253

@@ -1745,6 +1764,21 @@ Loading and running tests
17451764

17461765
This affects all the :meth:`loadTestsFrom\*` methods.
17471766

1767+
.. attribute:: testNamePatterns
1768+
1769+
List of Unix shell-style wildcard test name patterns that test methods
1770+
have to match to be included in test suites (see ``-v`` option).
1771+
1772+
If this attribute is not ``None`` (the default), all test methods to be
1773+
included in test suites must match one of the patterns in this list.
1774+
Note that matches are always performed using :meth:`fnmatch.fnmatchcase`,
1775+
so unlike patterns passed to the ``-v`` option, simple substring patterns
1776+
will have to be converted using ``*`` wildcards.
1777+
1778+
This affects all the :meth:`loadTestsFrom\*` methods.
1779+
1780+
.. versionadded:: 3.7
1781+
17481782

17491783
.. class:: TestResult
17501784

Lib/unittest/loader.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import functools
99
import warnings
1010

11-
from fnmatch import fnmatch
11+
from fnmatch import fnmatch, fnmatchcase
1212

1313
from . import case, suite, util
1414

@@ -70,6 +70,7 @@ class TestLoader(object):
7070
"""
7171
testMethodPrefix = 'test'
7272
sortTestMethodsUsing = staticmethod(util.three_way_cmp)
73+
testNamePatterns = None
7374
suiteClass = suite.TestSuite
7475
_top_level_dir = None
7576

@@ -222,11 +223,15 @@ def loadTestsFromNames(self, names, module=None):
222223
def getTestCaseNames(self, testCaseClass):
223224
"""Return a sorted sequence of method names found within testCaseClass
224225
"""
225-
def isTestMethod(attrname, testCaseClass=testCaseClass,
226-
prefix=self.testMethodPrefix):
227-
return attrname.startswith(prefix) and \
228-
callable(getattr(testCaseClass, attrname))
229-
testFnNames = list(filter(isTestMethod, dir(testCaseClass)))
226+
def shouldIncludeMethod(attrname):
227+
testFunc = getattr(testCaseClass, attrname)
228+
isTestMethod = attrname.startswith(self.testMethodPrefix) and callable(testFunc)
229+
if not isTestMethod:
230+
return False
231+
fullName = '%s.%s' % (testCaseClass.__module__, testFunc.__qualname__)
232+
return self.testNamePatterns is None or \
233+
any(fnmatchcase(fullName, pattern) for pattern in self.testNamePatterns)
234+
testFnNames = list(filter(shouldIncludeMethod, dir(testCaseClass)))
230235
if self.sortTestMethodsUsing:
231236
testFnNames.sort(key=functools.cmp_to_key(self.sortTestMethodsUsing))
232237
return testFnNames
@@ -486,16 +491,17 @@ def _find_test_path(self, full_path, pattern, namespace=False):
486491
defaultTestLoader = TestLoader()
487492

488493

489-
def _makeLoader(prefix, sortUsing, suiteClass=None):
494+
def _makeLoader(prefix, sortUsing, suiteClass=None, testNamePatterns=None):
490495
loader = TestLoader()
491496
loader.sortTestMethodsUsing = sortUsing
492497
loader.testMethodPrefix = prefix
498+
loader.testNamePatterns = testNamePatterns
493499
if suiteClass:
494500
loader.suiteClass = suiteClass
495501
return loader
496502

497-
def getTestCaseNames(testCaseClass, prefix, sortUsing=util.three_way_cmp):
498-
return _makeLoader(prefix, sortUsing).getTestCaseNames(testCaseClass)
503+
def getTestCaseNames(testCaseClass, prefix, sortUsing=util.three_way_cmp, testNamePatterns=None):
504+
return _makeLoader(prefix, sortUsing, testNamePatterns=testNamePatterns).getTestCaseNames(testCaseClass)
499505

500506
def makeSuite(testCaseClass, prefix='test', sortUsing=util.three_way_cmp,
501507
suiteClass=suite.TestSuite):

Lib/unittest/main.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,20 @@ def _convert_names(names):
4646
return [_convert_name(name) for name in names]
4747

4848

49+
def _convert_select_pattern(pattern):
50+
if not '*' in pattern:
51+
pattern = '*%s*' % pattern
52+
return pattern
53+
54+
4955
class TestProgram(object):
5056
"""A command-line program that runs a set of tests; this is primarily
5157
for making test modules conveniently executable.
5258
"""
5359
# defaults for testing
5460
module=None
5561
verbosity = 1
56-
failfast = catchbreak = buffer = progName = warnings = None
62+
failfast = catchbreak = buffer = progName = warnings = testNamePatterns = None
5763
_discovery_parser = None
5864

5965
def __init__(self, module='__main__', defaultTest=None, argv=None,
@@ -140,8 +146,13 @@ def parseArgs(self, argv):
140146
self.testNames = list(self.defaultTest)
141147
self.createTests()
142148

143-
def createTests(self):
144-
if self.testNames is None:
149+
def createTests(self, from_discovery=False, Loader=None):
150+
if self.testNamePatterns:
151+
self.testLoader.testNamePatterns = self.testNamePatterns
152+
if from_discovery:
153+
loader = self.testLoader if Loader is None else Loader()
154+
self.test = loader.discover(self.start, self.pattern, self.top)
155+
elif self.testNames is None:
145156
self.test = self.testLoader.loadTestsFromModule(self.module)
146157
else:
147158
self.test = self.testLoader.loadTestsFromNames(self.testNames,
@@ -179,6 +190,11 @@ def _getParentArgParser(self):
179190
action='store_true',
180191
help='Buffer stdout and stderr during tests')
181192
self.buffer = False
193+
if self.testNamePatterns is None:
194+
parser.add_argument('-k', dest='testNamePatterns',
195+
action='append', type=_convert_select_pattern,
196+
help='Only run tests which match the given substring')
197+
self.testNamePatterns = []
182198

183199
return parser
184200

@@ -225,8 +241,7 @@ def _do_discovery(self, argv, Loader=None):
225241
self._initArgParsers()
226242
self._discovery_parser.parse_args(argv, self)
227243

228-
loader = self.testLoader if Loader is None else Loader()
229-
self.test = loader.discover(self.start, self.pattern, self.top)
244+
self.createTests(from_discovery=True, Loader=Loader)
230245

231246
def runTests(self):
232247
if self.catchbreak:

Lib/unittest/test/test_loader.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,6 +1226,33 @@ def test_3(self): pass
12261226
names = ['test_1', 'test_2', 'test_3']
12271227
self.assertEqual(loader.getTestCaseNames(TestC), names)
12281228

1229+
# "Return a sorted sequence of method names found within testCaseClass"
1230+
#
1231+
# If TestLoader.testNamePatterns is set, only tests that match one of these
1232+
# patterns should be included.
1233+
def test_getTestCaseNames__testNamePatterns(self):
1234+
class MyTest(unittest.TestCase):
1235+
def test_1(self): pass
1236+
def test_2(self): pass
1237+
def foobar(self): pass
1238+
1239+
loader = unittest.TestLoader()
1240+
1241+
loader.testNamePatterns = []
1242+
self.assertEqual(loader.getTestCaseNames(MyTest), [])
1243+
1244+
loader.testNamePatterns = ['*1']
1245+
self.assertEqual(loader.getTestCaseNames(MyTest), ['test_1'])
1246+
1247+
loader.testNamePatterns = ['*1', '*2']
1248+
self.assertEqual(loader.getTestCaseNames(MyTest), ['test_1', 'test_2'])
1249+
1250+
loader.testNamePatterns = ['*My*']
1251+
self.assertEqual(loader.getTestCaseNames(MyTest), ['test_1', 'test_2'])
1252+
1253+
loader.testNamePatterns = ['*my*']
1254+
self.assertEqual(loader.getTestCaseNames(MyTest), [])
1255+
12291256
################################################################
12301257
### /Tests for TestLoader.getTestCaseNames()
12311258

Lib/unittest/test/test_program.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import sys
5+
import subprocess
56
from test import support
67
import unittest
78
import unittest.test
@@ -409,6 +410,33 @@ def testParseArgsAbsolutePathsThatCannotBeConverted(self):
409410
# for invalid filenames should we raise a useful error rather than
410411
# leaving the current error message (import of filename fails) in place?
411412

413+
def testParseArgsSelectedTestNames(self):
414+
program = self.program
415+
argv = ['progname', '-k', 'foo', '-k', 'bar', '-k', '*pat*']
416+
417+
program.createTests = lambda: None
418+
program.parseArgs(argv)
419+
420+
self.assertEqual(program.testNamePatterns, ['*foo*', '*bar*', '*pat*'])
421+
422+
def testSelectedTestNamesFunctionalTest(self):
423+
def run_unittest(args):
424+
p = subprocess.Popen([sys.executable, '-m', 'unittest'] + args,
425+
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, cwd=os.path.dirname(__file__))
426+
with p:
427+
_, stderr = p.communicate()
428+
return stderr.decode()
429+
430+
t = '_test_warnings'
431+
self.assertIn('Ran 7 tests', run_unittest([t]))
432+
self.assertIn('Ran 7 tests', run_unittest(['-k', 'TestWarnings', t]))
433+
self.assertIn('Ran 7 tests', run_unittest(['discover', '-p', '*_test*', '-k', 'TestWarnings']))
434+
self.assertIn('Ran 2 tests', run_unittest(['-k', 'f', t]))
435+
self.assertIn('Ran 7 tests', run_unittest(['-k', 't', t]))
436+
self.assertIn('Ran 3 tests', run_unittest(['-k', '*t', t]))
437+
self.assertIn('Ran 7 tests', run_unittest(['-k', '*test_warnings.*Warning*', t]))
438+
self.assertIn('Ran 1 test', run_unittest(['-k', '*test_warnings.*warning*', t]))
439+
412440

413441
if __name__ == '__main__':
414442
unittest.main()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Added the ``-k`` command-line option to ``python -m unittest`` to run only
2+
tests that match the given pattern(s).

0 commit comments

Comments
 (0)