diff --git a/avocado/core/loader.py b/avocado/core/loader.py index cd78f58ea6b22d81b4b9877411dcf618095039c1..727636bac3679fb165bbe2ab864a376330a24999 100644 --- a/avocado/core/loader.py +++ b/avocado/core/loader.py @@ -594,12 +594,14 @@ class FileLoader(TestLoader): tests.extend(self._make_tests(pth, which_tests)) return tests - def _find_avocado_tests(self, path): + def _find_avocado_tests(self, path, class_name=None): """ Attempts to find Avocado instrumented tests from Python source files :param path: path to a Python source code file :type path: str + :param class_name: the specific class to be found + :type path: str :returns: dict with class name and additional info such as method names and tags :rtype: dict @@ -615,6 +617,9 @@ class FileLoader(TestLoader): # The resulting test classes result = {} + if os.path.isdir(path): + path = os.path.join(path, "__init__.py") + mod = ast.parse(open(path).read(), path) for statement in mod.body: @@ -643,18 +648,99 @@ class FileLoader(TestLoader): # Looking for a 'class Anything(anything):' elif isinstance(statement, ast.ClassDef): + + # class_name will exist only under recursion. In that + # case, we will only process the class if it has the + # expected class_name. + if class_name is not None and class_name != statement.name: + continue + docstring = ast.get_docstring(statement) # Looking for a class that has in the docstring either # ":avocado: enable" or ":avocado: disable - if safeloader.check_docstring_directive(docstring, 'disable'): + if (safeloader.check_docstring_directive(docstring, 'disable') + and class_name is None): continue cl_tags = safeloader.get_docstring_directives_tags(docstring) - if safeloader.check_docstring_directive(docstring, 'enable'): - info = self._get_methods_info(statement.body, - cl_tags) + if (safeloader.check_docstring_directive(docstring, 'enable') + and class_name is None): + info = self._get_methods_info(statement.body, cl_tags) + result[statement.name] = info + continue + + # Looking for the 'recursive' docstring or a 'class_name' + # (meaning we are under recursion) + if (safeloader.check_docstring_directive(docstring, 'recursive') + or class_name is not None): + info = self._get_methods_info(statement.body, cl_tags) result[statement.name] = info + + # Getting the list of parents of the current class + parents = statement.bases + + # Searching the parents in the same module + for parent in parents[:]: + # Looking for a 'class FooTest(module.Parent)' + if isinstance(parent, ast.Attribute): + parent_class = parent.attr + # Looking for a 'class FooTest(Parent)' + else: + parent_class = parent.id + + res = self._find_avocado_tests(path, parent_class) + if res: + parents.remove(parent) + for cls in res: + info.extend(res[cls]) + + # If there are parents left to be discovered, they + # might be in a different module. + for parent in parents: + if isinstance(parent, ast.Attribute): + # Looking for a 'class FooTest(module.Parent)' + parent_module = parent.value.id + parent_class = parent.attr + else: + # Looking for a 'class FooTest(Parent)' + parent_module = None + parent_class = parent.id + + for node in mod.body: + reference = None + # Looking for 'from parent import class' + if isinstance(node, ast.ImportFrom): + reference = parent_class + # Looking for 'import parent' + elif isinstance(node, ast.Import): + reference = parent_module + + if reference is None: + continue + + for artifact in node.names: + # Looking for a class alias + # ('from parent import class as alias') + if artifact.asname is not None: + parent_class = reference = artifact.name + # If the parent class or the parent module + # is found in the imports, discover the + # parent module path and find the parent + # class there + if artifact.name == reference: + modules_paths = [os.path.dirname(path)] + modules_paths.extend(sys.path) + if parent_module is None: + parent_module = node.module + _, ppath, _ = imp.find_module(parent_module, + modules_paths) + res = self._find_avocado_tests(ppath, + parent_class) + if res: + for cls in res: + info.extend(res[cls]) + continue if test_import: @@ -676,6 +762,7 @@ class FileLoader(TestLoader): info = self._get_methods_info(statement.body, cl_tags) result[statement.name] = info + continue return result diff --git a/docs/source/WritingTests.rst b/docs/source/WritingTests.rst index e9a54ccaed387ee11b1df2e0d8ea21fcfaf87a55..223cc38053fbd5d8bebf64e190fdcbd262b7387e 100644 --- a/docs/source/WritingTests.rst +++ b/docs/source/WritingTests.rst @@ -1251,6 +1251,68 @@ The docstring ``:avocado: disable`` is evaluated first by Avocado, meaning that if both ``:avocado: disable`` and ``:avocado: enable`` are present in the same docstring, the test will not be listed. + +Recursively Discovering Tests +----------------------------- + +In addition to the ``:avocado: enable`` and ``:avocado: disable`` +docstring directives, Avocado has support for the ``:avocado: recursive`` +directive. It is intended to be used in inherited classes when you want +to tell Avocado to also discover the ancestor classes. + +The ``:avocado: recursive`` directive will direct Avocado to evaluate all +the ancestors of the class until the base class, the one derived from +from `avocado.Test`. + +Example: + +File `/usr/share/avocado/tests/test_base_class.py`:: + + from avocado import Test + + + class BaseClass(Test): + + def test_basic(self): + pass + + +File `/usr/share/avocado/tests/test_first_child.py`:: + + from test_base_class import BaseClass + + + class FirstChild(BaseClass): + + def test_first_child(self): + pass + + +File `/usr/share/avocado/tests/test_second_child.py`:: + + from test_first_child import FirstChild + + + class SecondChild(FirstChild): + """ + :avocado: recursive + """ + + def test_second_child(self): + pass + +Using only `test_second_child.py` as a test reference will result in:: + + $ avocado list test_second_child.py + INSTRUMENTED test_second_child.py:SecondChild.test_second_child + INSTRUMENTED test_second_child.py:SecondChild.test_first_child + INSTRUMENTED test_second_child.py:SecondChild.test_basic + +Notice that the `:avocado: disable` docstring will be ignored in +ancestors during the recursive discovery. What means that even if an +ancestor contains the docstring `:avocado: disable`, that ancestor will +still be included in the results. + .. _categorizing-tests: Categorizing tests diff --git a/selftests/unit/test_loader.py b/selftests/unit/test_loader.py index 0532dcb8e98cccafd77f5c2fb9d93336542a9fc9..b7db38afafd70c0add79a7e7886de968c23cd5d2 100644 --- a/selftests/unit/test_loader.py +++ b/selftests/unit/test_loader.py @@ -1,5 +1,7 @@ +import os import shutil import stat +import sys import multiprocessing import tempfile import unittest @@ -205,6 +207,37 @@ class MyClass(Test): pass ''' +RECURSIVE_DISCOVERY_TEST1 = """ +from avocado import Test + +class BaseClass(Test): + def test_basic(self): + pass + +class FirstChild(BaseClass): + def test_first_child(self): + pass + +class SecondChild(FirstChild): + ''' + :avocado: disable + ''' + def test_second_child(self): + pass +""" + +RECURSIVE_DISCOVERY_TEST2 = """ +from avocado import Test +from recursive_discovery_test1 import SecondChild + +class ThirdChild(Test, SecondChild): + ''' + :avocado: recursive + ''' + def test_third_child(self): + pass +""" + class LoaderTest(unittest.TestCase): @@ -485,6 +518,24 @@ class LoaderTest(unittest.TestCase): self.assertEqual(expected_order, methods) avocado_keep_methods_order.remove() + def test_recursive_discovery(self): + avocado_recursive_discovery_test1 = script.TemporaryScript( + 'recursive_discovery_test1.py', + RECURSIVE_DISCOVERY_TEST1) + avocado_recursive_discovery_test1.save() + avocado_recursive_discovery_test2 = script.TemporaryScript( + 'recursive_discovery_test2.py', + RECURSIVE_DISCOVERY_TEST2) + avocado_recursive_discovery_test2.save() + + sys.path.append(os.path.dirname(avocado_recursive_discovery_test1.path)) + tests = self.loader._find_avocado_tests(avocado_recursive_discovery_test2.path) + expected = {'ThirdChild': [('test_third_child', set([])), + ('test_second_child', set([])), + ('test_first_child', set([])), + ('test_basic', set([]))]} + self.assertEqual(expected, tests) + def tearDown(self): shutil.rmtree(self.tmpdir)