]> granicus.if.org Git - python/commitdiff
bpo-32071: Add unittest -k option (#4496)
authorJonas Haag <jonas@lophus.org>
Sat, 25 Nov 2017 15:23:52 +0000 (16:23 +0100)
committerAntoine Pitrou <pitrou@free.fr>
Sat, 25 Nov 2017 15:23:52 +0000 (16:23 +0100)
* bpo-32071: Add unittest -k option

Doc/library/unittest.rst
Lib/unittest/loader.py
Lib/unittest/main.py
Lib/unittest/test/test_loader.py
Lib/unittest/test/test_program.py
Misc/NEWS.d/next/Library/2017-11-22-19-52-17.bpo-32071.4WNhUH.rst [new file with mode: 0644]

index e52f140029c57ab56ac683c68ab09cd60801f55f..4755488d91db061981547a87de44ee6a1c5ffea5 100644 (file)
@@ -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.
@@ -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.
 
@@ -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
 
index e860debc0f39204a4e58ea71ef244144d91b8d7b..eb03b4ab87707a10e84c944b65805c0ef28d9a01 100644 (file)
@@ -8,7 +8,7 @@ import types
 import functools
 import warnings
 
-from fnmatch import fnmatch
+from fnmatch import fnmatch, fnmatchcase
 
 from . import case, suite, util
 
@@ -70,6 +70,7 @@ class TestLoader(object):
     """
     testMethodPrefix = 'test'
     sortTestMethodsUsing = staticmethod(util.three_way_cmp)
+    testNamePatterns = None
     suiteClass = suite.TestSuite
     _top_level_dir = None
 
@@ -222,11 +223,15 @@ class TestLoader(object):
     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
@@ -486,16 +491,17 @@ class TestLoader(object):
 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):
index 807604f08dfd141235cfe04a0ee6ff23a0d584da..e62469aa2a170f2bc1894adc232cf46cdeb2b4d9 100644 (file)
@@ -46,6 +46,12 @@ 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.
@@ -53,7 +59,7 @@ class TestProgram(object):
     # 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,
@@ -140,8 +146,13 @@ class TestProgram(object):
             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,
@@ -179,6 +190,11 @@ class TestProgram(object):
                                 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
 
@@ -225,8 +241,7 @@ class TestProgram(object):
                 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)
 
     def runTests(self):
         if self.catchbreak:
index 1131a755eaa3f2ba7a41e90603a8af6db0fbf06b..15b01863f5145457ef90e592d6de192069c24802 100644 (file)
@@ -1226,6 +1226,33 @@ class Test_TestLoader(unittest.TestCase):
         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()
 
index 1cfc17959e074aa5b0817b3fafdd50b6d710bfb3..4a62ae1b11306ecc843f34add0ae265d147529c1 100644 (file)
@@ -2,6 +2,7 @@ import io
 
 import os
 import sys
+import subprocess
 from test import support
 import unittest
 import unittest.test
@@ -409,6 +410,33 @@ class TestCommandLineArgs(unittest.TestCase):
         # 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):
+        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()
diff --git a/Misc/NEWS.d/next/Library/2017-11-22-19-52-17.bpo-32071.4WNhUH.rst b/Misc/NEWS.d/next/Library/2017-11-22-19-52-17.bpo-32071.4WNhUH.rst
new file mode 100644 (file)
index 0000000..2f0f540
--- /dev/null
@@ -0,0 +1,2 @@
+Added the ``-k`` command-line option to ``python -m unittest`` to run only
+tests that match the given pattern(s).