Skip to content

bpo-32071: Add unittest -k option #4496

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Nov 25, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions Doc/library/unittest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,22 @@ Command-line options

Stop the test run on the first error or failure.

.. cmdoption:: -k

Only run test methods and classes that match the pattern or substring.
This option may be used multiple times, in which case all test cases that
match of the given patterns are included.

Patterns that contain a wildcard character (``*``) are matched against the
test name using :meth:`fnmatch.fnmatchcase`; otherwise simple case-sensitive
substring matching is used.

Patterns are matched against the fully qualified test method name as
imported by the test loader.

For example, ``-k foo`` matches ``foo_tests.SomeTest.test_something``,
``bar_tests.SomeTest.test_foo``, but not ``bar_tests.FooTest.test_something``.

.. cmdoption:: --locals

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

.. versionadded:: 3.7
The command-line option ``-k``.

The command line can also be used for test discovery, for running all of the
tests in a project or just a subset.

Expand Down Expand Up @@ -1745,6 +1764,21 @@ Loading and running tests

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

.. attribute:: testNamePatterns

List of Unix shell-style wildcard test name patterns that test methods
have to match to be included in test suites (see ``-v`` option).

If this attribute is not ``None`` (the default), all test methods to be
included in test suites must match one of the patterns in this list.
Note that matches are always performed using :meth:`fnmatch.fnmatchcase`,
so unlike patterns passed to the ``-v`` option, simple substring patterns
will have to be converted using ``*`` wildcards.

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

.. versionadded:: 3.7


.. class:: TestResult

Expand Down
24 changes: 15 additions & 9 deletions Lib/unittest/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import functools
import warnings

from fnmatch import fnmatch
from fnmatch import fnmatch, fnmatchcase

from . import case, suite, util

Expand Down Expand Up @@ -70,6 +70,7 @@ class TestLoader(object):
"""
testMethodPrefix = 'test'
sortTestMethodsUsing = staticmethod(util.three_way_cmp)
testNamePatterns = None
suiteClass = suite.TestSuite
_top_level_dir = None

Expand Down Expand Up @@ -222,11 +223,15 @@ def loadTestsFromNames(self, names, module=None):
def getTestCaseNames(self, testCaseClass):
"""Return a sorted sequence of method names found within testCaseClass
"""
def isTestMethod(attrname, testCaseClass=testCaseClass,
prefix=self.testMethodPrefix):
return attrname.startswith(prefix) and \
callable(getattr(testCaseClass, attrname))
testFnNames = list(filter(isTestMethod, dir(testCaseClass)))
def shouldIncludeMethod(attrname):
testFunc = getattr(testCaseClass, attrname)
isTestMethod = attrname.startswith(self.testMethodPrefix) and callable(testFunc)
if not isTestMethod:
return False
fullName = '%s.%s' % (testCaseClass.__module__, testFunc.__qualname__)
return self.testNamePatterns is None or \
any(fnmatchcase(fullName, pattern) for pattern in self.testNamePatterns)
testFnNames = list(filter(shouldIncludeMethod, dir(testCaseClass)))
if self.sortTestMethodsUsing:
testFnNames.sort(key=functools.cmp_to_key(self.sortTestMethodsUsing))
return testFnNames
Expand Down Expand Up @@ -486,16 +491,17 @@ def _find_test_path(self, full_path, pattern, namespace=False):
defaultTestLoader = TestLoader()


def _makeLoader(prefix, sortUsing, suiteClass=None):
def _makeLoader(prefix, sortUsing, suiteClass=None, testNamePatterns=None):
loader = TestLoader()
loader.sortTestMethodsUsing = sortUsing
loader.testMethodPrefix = prefix
loader.testNamePatterns = testNamePatterns
if suiteClass:
loader.suiteClass = suiteClass
return loader

def getTestCaseNames(testCaseClass, prefix, sortUsing=util.three_way_cmp):
return _makeLoader(prefix, sortUsing).getTestCaseNames(testCaseClass)
def getTestCaseNames(testCaseClass, prefix, sortUsing=util.three_way_cmp, testNamePatterns=None):
return _makeLoader(prefix, sortUsing, testNamePatterns=testNamePatterns).getTestCaseNames(testCaseClass)

def makeSuite(testCaseClass, prefix='test', sortUsing=util.three_way_cmp,
suiteClass=suite.TestSuite):
Expand Down
25 changes: 20 additions & 5 deletions Lib/unittest/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,20 @@ def _convert_names(names):
return [_convert_name(name) for name in names]


def _convert_select_pattern(pattern):
if not '*' in pattern:
pattern = '*%s*' % pattern
return pattern


class TestProgram(object):
"""A command-line program that runs a set of tests; this is primarily
for making test modules conveniently executable.
"""
# defaults for testing
module=None
verbosity = 1
failfast = catchbreak = buffer = progName = warnings = None
failfast = catchbreak = buffer = progName = warnings = testNamePatterns = None
_discovery_parser = None

def __init__(self, module='__main__', defaultTest=None, argv=None,
Expand Down Expand Up @@ -140,8 +146,13 @@ def parseArgs(self, argv):
self.testNames = list(self.defaultTest)
self.createTests()

def createTests(self):
if self.testNames is None:
def createTests(self, from_discovery=False, Loader=None):
if self.testNamePatterns:
self.testLoader.testNamePatterns = self.testNamePatterns
if from_discovery:
loader = self.testLoader if Loader is None else Loader()
self.test = loader.discover(self.start, self.pattern, self.top)
elif self.testNames is None:
self.test = self.testLoader.loadTestsFromModule(self.module)
else:
self.test = self.testLoader.loadTestsFromNames(self.testNames,
Expand Down Expand Up @@ -179,6 +190,11 @@ def _getParentArgParser(self):
action='store_true',
help='Buffer stdout and stderr during tests')
self.buffer = False
if self.testNamePatterns is None:
parser.add_argument('-k', dest='testNamePatterns',
action='append', type=_convert_select_pattern,
help='Only run tests which match the given substring')
self.testNamePatterns = []

return parser

Expand Down Expand Up @@ -225,8 +241,7 @@ def _do_discovery(self, argv, Loader=None):
self._initArgParsers()
self._discovery_parser.parse_args(argv, self)

loader = self.testLoader if Loader is None else Loader()
self.test = loader.discover(self.start, self.pattern, self.top)
self.createTests(from_discovery=True, Loader=Loader)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious, why was this change necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This refactoring was necessary because I didn't want to repeat the code that sets loader.testNamePatterns from the CLI options. That code has to be executed BEFORE loader.discover. Besides, I believe it makes sense to have a single place where self.test is set.


def runTests(self):
if self.catchbreak:
Expand Down
27 changes: 27 additions & 0 deletions Lib/unittest/test/test_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,33 @@ def test_3(self): pass
names = ['test_1', 'test_2', 'test_3']
self.assertEqual(loader.getTestCaseNames(TestC), names)

# "Return a sorted sequence of method names found within testCaseClass"
#
# If TestLoader.testNamePatterns is set, only tests that match one of these
# patterns should be included.
def test_getTestCaseNames__testNamePatterns(self):
class MyTest(unittest.TestCase):
def test_1(self): pass
def test_2(self): pass
def foobar(self): pass

loader = unittest.TestLoader()

loader.testNamePatterns = []
self.assertEqual(loader.getTestCaseNames(MyTest), [])

loader.testNamePatterns = ['*1']
self.assertEqual(loader.getTestCaseNames(MyTest), ['test_1'])

loader.testNamePatterns = ['*1', '*2']
self.assertEqual(loader.getTestCaseNames(MyTest), ['test_1', 'test_2'])

loader.testNamePatterns = ['*My*']
self.assertEqual(loader.getTestCaseNames(MyTest), ['test_1', 'test_2'])

loader.testNamePatterns = ['*my*']
self.assertEqual(loader.getTestCaseNames(MyTest), [])

################################################################
### /Tests for TestLoader.getTestCaseNames()

Expand Down
28 changes: 28 additions & 0 deletions Lib/unittest/test/test_program.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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

def testParseArgsSelectedTestNames(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for adding these tests. Do you think you could also create a small functional test? There is an example of such a test (using subprocess) in Test_TextTestRunner.test_warnings.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a functional test but I'm not sure if that's the right place to put it.

program = self.program
argv = ['progname', '-k', 'foo', '-k', 'bar', '-k', '*pat*']

program.createTests = lambda: None
program.parseArgs(argv)

self.assertEqual(program.testNamePatterns, ['*foo*', '*bar*', '*pat*'])

def testSelectedTestNamesFunctionalTest(self):
def run_unittest(args):
p = subprocess.Popen([sys.executable, '-m', 'unittest'] + args,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, cwd=os.path.dirname(__file__))
with p:
_, stderr = p.communicate()
return stderr.decode()

t = '_test_warnings'
self.assertIn('Ran 7 tests', run_unittest([t]))
self.assertIn('Ran 7 tests', run_unittest(['-k', 'TestWarnings', t]))
self.assertIn('Ran 7 tests', run_unittest(['discover', '-p', '*_test*', '-k', 'TestWarnings']))
self.assertIn('Ran 2 tests', run_unittest(['-k', 'f', t]))
self.assertIn('Ran 7 tests', run_unittest(['-k', 't', t]))
self.assertIn('Ran 3 tests', run_unittest(['-k', '*t', t]))
self.assertIn('Ran 7 tests', run_unittest(['-k', '*test_warnings.*Warning*', t]))
self.assertIn('Ran 1 test', run_unittest(['-k', '*test_warnings.*warning*', t]))


if __name__ == '__main__':
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Added the ``-k`` command-line option to ``python -m unittest`` to run only
tests that match the given pattern(s).