From f821c47bc6c62c8a55b5d56c043a9dabedbd4b7e Mon Sep 17 00:00:00 2001 From: "Chris St. Pierre" Date: Wed, 28 Oct 2015 21:34:22 -0500 Subject: [PATCH] Add AST checker for missing @ddt.ddt decorators If a test case class has functions decorated with a ddt decorator, but is not itself decorated with @ddt.ddt, then tests don't run properly. This adds an AST checker to ensure that a class that contains functions decorated with a ddt decorator is properly decorated. It also includes unit tests for the checker itself. Change-Id: If7186d1f8b9a1967e7af55d86cd568e323aeccf7 --- tests/unit/test_ddt.py | 116 ++++++++++++++++++++++++++++++++++++ tests/unit/test_test_ddt.py | 48 +++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 tests/unit/test_ddt.py create mode 100644 tests/unit/test_test_ddt.py diff --git a/tests/unit/test_ddt.py b/tests/unit/test_ddt.py new file mode 100644 index 0000000000..c476040c8b --- /dev/null +++ b/tests/unit/test_ddt.py @@ -0,0 +1,116 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import ast +import os + +from tests.unit import test + + +class DDTDecoratorChecker(ast.NodeVisitor): + """Visit an AST tree looking for classes lacking the ddt.ddt decorator. + + DDT uses decorators on test case functions to supply different + test data, but if the class that those functions are members of is + not decorated with @ddt.ddt, then the data expansion never happens + and the tests are incomplete. This is very easy to miss both when + writing and when reviewing code, so this visitor ensures that + every class that contains a function decorated with a @ddt.* + decorator is itself decorated with @ddt.ddt + """ + def __init__(self): + self.classes = [] + self.errors = {} + + @classmethod + def _get_name(cls, node): + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return cls._get_name(node.value) + "." + node.attr + return "" + + def _is_ddt(self, cls_node): + return "ddt.ddt" in (self._get_name(d) + for d in cls_node.decorator_list) + + def visit_ClassDef(self, node): + self.classes.append(node) + self.generic_visit(node) + self.classes.pop() + + def visit_FunctionDef(self, node): + if not self.classes: + # NOTE(stpierre): we only care about functions that are + # defined inside of classes + return + cls = self.classes[-1] + if cls.name in self.errors: + # NOTE(stpierre): if this class already has been found to + # be in error, ignore the rest of its functions + return + for decorator in node.decorator_list: + if not isinstance(decorator, ast.Call): + continue + funcname = self._get_name(decorator.func) + if funcname.startswith("ddt."): + if not self._is_ddt(cls): + msg = ("Class %s has functions that use DDT, " + "but is not decorated with `ddt.ddt`" % + cls.name) + self.errors[cls.name] = { + "lineno": node.lineno, + "message": msg + } + + +class DDTDecoratorCheckerTestCase(test.TestCase): + tests_path = os.path.join(os.path.dirname(__file__)) + + def test_ddt_class_decorator(self): + """Classes with DDT-decorated functions have ddt.ddt class decorator. + + """ + errors = [] + + for dirname, dirnames, filenames in os.walk(self.tests_path): + for filename in filenames: + if not (filename.startswith("test_") and + filename.endswith(".py")): + continue + + filename = os.path.relpath(os.path.join(dirname, filename)) + + with open(filename, "rb") as fh: + try: + tree = ast.parse(fh.read(), filename) + except TypeError as err: + errors.append({"message": str(err), + "filename": filename, + "lineno": -1}) + + visitor = DDTDecoratorChecker() + visitor.visit(tree) + errors.extend( + dict(filename=filename, **error) + for error in visitor.errors.values()) + + if errors: + msg = [""] + for error in errors: + msg.extend([ + "Errors at %(filename)s line %(lineno)d: %(message)s" % { + "message": error["message"], + "filename": error["filename"], + "lineno": error["lineno"]}, + ""]) + self.fail("\n".join(msg)) diff --git a/tests/unit/test_test_ddt.py b/tests/unit/test_test_ddt.py new file mode 100644 index 0000000000..1a9b3a0001 --- /dev/null +++ b/tests/unit/test_test_ddt.py @@ -0,0 +1,48 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import ast + +from tests.unit import test +from tests.unit import test_ddt + + +class DDTDecoratorCheckerTestCase(test.TestCase): + + def test_pass(self): + code = """ +@ddt.ddt +class Test(object): + @ddt.data({}) + def test_func(self): + pass +""" + tree = ast.parse(code).body[0] + visitor = test_ddt.DDTDecoratorChecker() + visitor.visit(tree) + self.assertEqual(visitor.errors, {}) + + def test_fail(self): + code = """ +class Test(object): + @ddt.data({}) + def test_func(self): + pass +""" + tree = ast.parse(code).body[0] + visitor = test_ddt.DDTDecoratorChecker() + visitor.visit(tree) + self.assertEqual( + visitor.errors, + {"Test": {"lineno": 3, + "message": "Class Test has functions that use DDT, " + "but is not decorated with `ddt.ddt`"}})