Merge "unit-tests: add mock decorators usage test checker"
This commit is contained in:
commit
c69f142360
248
tests/unit/test_mock.py
Normal file
248
tests/unit/test_mock.py
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
# 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
|
||||||
|
import re
|
||||||
|
|
||||||
|
import six.moves
|
||||||
|
|
||||||
|
from tests.unit import test
|
||||||
|
|
||||||
|
|
||||||
|
class FuncMockArgsDecoratorsChecker(ast.NodeVisitor):
|
||||||
|
"""Recursively visit an AST looking for misusage of mocks in tests.
|
||||||
|
|
||||||
|
The misusage being tested by this particular class is unmatched mocked
|
||||||
|
object name against the argument names.
|
||||||
|
|
||||||
|
The following is the correct usages::
|
||||||
|
@mock.patch("module.abc")
|
||||||
|
def test_foobar(self, mock_module_abc): # or `mock_abc'
|
||||||
|
...
|
||||||
|
|
||||||
|
@mock.patch("pkg.ClassName.abc")
|
||||||
|
def test_foobar(self, mock_class_name_abc):
|
||||||
|
...
|
||||||
|
|
||||||
|
class FooClassNameTestCase(...):
|
||||||
|
@mock.patch("pkg.FooClassName.abc")
|
||||||
|
def test_foobar(self, mock_abc):
|
||||||
|
# Iff the mocked object is inside the tested class then
|
||||||
|
# the class name in mock argname is optional.
|
||||||
|
...
|
||||||
|
|
||||||
|
While these are not::
|
||||||
|
@mock.patch("module.abc")
|
||||||
|
def test_foobar(self, m_abc):
|
||||||
|
# must be prefixed with `mock_'
|
||||||
|
|
||||||
|
@mock.patch("module.abc")
|
||||||
|
def test_foobar(self, mock_cba):
|
||||||
|
# must contain mocked object name (`mock_abc')
|
||||||
|
|
||||||
|
@mock.patch("module.abc")
|
||||||
|
def test_foobar(self, mock_modulewrong_abc):
|
||||||
|
# must match the module `mock_module_abc'
|
||||||
|
|
||||||
|
@mock.patch("ClassName.abc")
|
||||||
|
def test_foobar(self, mock_class_abc):
|
||||||
|
# must match the python-styled class name + method name
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
self.errors = []
|
||||||
|
self.globals_ = {}
|
||||||
|
|
||||||
|
@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 _get_value(self, node):
|
||||||
|
"""Get mock.patch string argument regexp.
|
||||||
|
|
||||||
|
It is either a string (if we are lucky), string-format of
|
||||||
|
("%s.something" % GVAL) or (GVAL + ".something")
|
||||||
|
"""
|
||||||
|
val = None
|
||||||
|
if isinstance(node, ast.Str):
|
||||||
|
val = node.s
|
||||||
|
elif isinstance(node, ast.BinOp):
|
||||||
|
if isinstance(node.op, ast.Mod):
|
||||||
|
val = node.left.s % self.globals_[node.right.id]
|
||||||
|
elif isinstance(node.op, ast.Add):
|
||||||
|
val = self.globals_[node.left.id] + node.right.s
|
||||||
|
elif isinstance(node, ast.Name):
|
||||||
|
val = self.globals_[node.id]
|
||||||
|
|
||||||
|
if val is None:
|
||||||
|
raise ValueError("Unable to find value in %s" % ast.dump(node))
|
||||||
|
|
||||||
|
return val
|
||||||
|
|
||||||
|
CAMELCASE_SPLIT_ANY_AND_CAPITAL = re.compile("(.)([A-Z][a-z]+)")
|
||||||
|
CAMELCASE_SPLIT_LOWER_AND_CAPITAL = re.compile("([a-z0-9])([A-Z])")
|
||||||
|
CAMELCASE_SPLIT_REPL = r"\1_\2"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _camelcase_to_python(cls, name):
|
||||||
|
for regexp in (cls.CAMELCASE_SPLIT_ANY_AND_CAPITAL,
|
||||||
|
cls.CAMELCASE_SPLIT_LOWER_AND_CAPITAL):
|
||||||
|
name = regexp.sub(cls.CAMELCASE_SPLIT_REPL, name)
|
||||||
|
return name.lower()
|
||||||
|
|
||||||
|
def _get_mocked_class_value_regexp(self, class_name, mocked_name):
|
||||||
|
class_name = self._camelcase_to_python(class_name)
|
||||||
|
mocked_name = self._camelcase_to_python(mocked_name)
|
||||||
|
|
||||||
|
if class_name == self.classname_python:
|
||||||
|
# Optional, since class name of the mocked package is the same as
|
||||||
|
# class name of the *TestCase
|
||||||
|
return "(?:" + class_name + "_)?" + mocked_name
|
||||||
|
|
||||||
|
# Full class name is required otherwise
|
||||||
|
return class_name + "_" + mocked_name
|
||||||
|
|
||||||
|
def _get_pkg_optional_regexp(self, tokens):
|
||||||
|
pkg_regexp = ""
|
||||||
|
for token in map(self._camelcase_to_python, tokens):
|
||||||
|
pkg_regexp = ("(?:" + pkg_regexp + "_)?" + token
|
||||||
|
if pkg_regexp else token)
|
||||||
|
return "(?:" + pkg_regexp + "_)?"
|
||||||
|
|
||||||
|
def _get_mocked_name_regexp(self, name):
|
||||||
|
tokens = name.split(".")
|
||||||
|
if len(tokens) > 1:
|
||||||
|
name = self._camelcase_to_python(tokens.pop())
|
||||||
|
if tokens[-1][0].isupper():
|
||||||
|
# Mocked something inside a class, check if we should require
|
||||||
|
# the class name to be present in mock argument
|
||||||
|
name = self._get_mocked_class_value_regexp(
|
||||||
|
class_name=tokens[-1],
|
||||||
|
mocked_name=name)
|
||||||
|
pkg_regexp = self._get_pkg_optional_regexp(tokens)
|
||||||
|
name = pkg_regexp + name
|
||||||
|
return name
|
||||||
|
|
||||||
|
def _get_mock_decorators_regexp(self, funccall):
|
||||||
|
"""Return all the mock.patch{,.object} decorated for function."""
|
||||||
|
mock_decorators = []
|
||||||
|
|
||||||
|
for decorator in reversed(funccall.decorator_list):
|
||||||
|
if not isinstance(decorator, ast.Call):
|
||||||
|
continue
|
||||||
|
funcname = self._get_name(decorator.func)
|
||||||
|
|
||||||
|
if funcname == "mock.patch":
|
||||||
|
decname = self._get_value(decorator.args[0])
|
||||||
|
elif funcname == "mock.patch.object":
|
||||||
|
decname = (self._get_name(decorator.args[0]) + "." +
|
||||||
|
self._get_value(decorator.args[1]))
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
decname = self._get_mocked_name_regexp(decname)
|
||||||
|
|
||||||
|
mock_decorators.append(decname)
|
||||||
|
|
||||||
|
return mock_decorators
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_mock_args(node):
|
||||||
|
"""Return all the mock arguments."""
|
||||||
|
args = []
|
||||||
|
PREFIX_LENGTH = len("mock_")
|
||||||
|
|
||||||
|
for arg in node.args.args:
|
||||||
|
name = getattr(arg, "id", getattr(arg, "arg", None))
|
||||||
|
if not name.startswith("mock_"):
|
||||||
|
continue
|
||||||
|
args.append(name[PREFIX_LENGTH:])
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
def visit_Assign(self, node):
|
||||||
|
"""Catch all the globals."""
|
||||||
|
self.generic_visit(node)
|
||||||
|
|
||||||
|
if node.col_offset == 0:
|
||||||
|
mnode = ast.Module(body=[node])
|
||||||
|
code = compile(mnode, "<ast>", "exec")
|
||||||
|
try:
|
||||||
|
exec(code, self.globals_)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def visit_ClassDef(self, node):
|
||||||
|
classname_camel = node.name
|
||||||
|
if node.name.endswith("TestCase"):
|
||||||
|
classname_camel = node.name[:-len("TestCase")]
|
||||||
|
|
||||||
|
self.classname_python = self._camelcase_to_python(classname_camel)
|
||||||
|
|
||||||
|
self.generic_visit(node)
|
||||||
|
|
||||||
|
def check_name(self, arg, dec):
|
||||||
|
return (arg is not None and dec is not None
|
||||||
|
and (arg == dec or re.match(dec, arg)))
|
||||||
|
|
||||||
|
def visit_FunctionDef(self, node):
|
||||||
|
self.generic_visit(node)
|
||||||
|
|
||||||
|
mock_decs = self._get_mock_decorators_regexp(node)
|
||||||
|
|
||||||
|
if not mock_decs:
|
||||||
|
return
|
||||||
|
|
||||||
|
mock_args = self._get_mock_args(node)
|
||||||
|
|
||||||
|
for arg, dec in six.moves.zip_longest(mock_args, mock_decs):
|
||||||
|
if not self.check_name(arg, dec):
|
||||||
|
self.errors.append({
|
||||||
|
"lineno": node.lineno,
|
||||||
|
"args": mock_args,
|
||||||
|
"decs": mock_decs
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
class MockUsageCheckerTestCase(test.TestCase):
|
||||||
|
tests_path = os.path.join(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
def test_mock_decorators_and_args(self):
|
||||||
|
"""Ensure that mocked objects are called correctly in the arguments.
|
||||||
|
|
||||||
|
See `FuncMockArgsDecoratorsChecker' docstring for details.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for dirname, dirnames, filenames in os.walk(self.tests_path):
|
||||||
|
for filename in filenames:
|
||||||
|
if (not filename.startswith("test_") or
|
||||||
|
not filename.endswith(".py")):
|
||||||
|
continue
|
||||||
|
|
||||||
|
filename = os.path.relpath(os.path.join(dirname, filename))
|
||||||
|
|
||||||
|
with open(filename, "rb") as fh:
|
||||||
|
tree = ast.parse(fh.read(), filename)
|
||||||
|
|
||||||
|
visitor = FuncMockArgsDecoratorsChecker()
|
||||||
|
visitor.visit(tree)
|
||||||
|
errors.extend(
|
||||||
|
dict(filename=filename, **error)
|
||||||
|
for error in visitor.errors)
|
||||||
|
|
||||||
|
self.assertEqual([], errors)
|
Loading…
Reference in New Issue
Block a user