diff --git a/src/mutmut/mutation/file_mutation.py b/src/mutmut/mutation/file_mutation.py index f0bb9cff..1c2f3d94 100644 --- a/src/mutmut/mutation/file_mutation.py +++ b/src/mutmut/mutation/file_mutation.py @@ -281,7 +281,12 @@ def _skip_node_and_children(self, node: cst.CSTNode) -> bool: if isinstance(decorator, cst.Name) and decorator.value in ("staticmethod", "classmethod"): return False return True - if isinstance(node, cst.ClassDef) and len(node.decorators): + + # decorators are executed at definition time, so mutating them can raise + # exceptions on import. A decorated class is still recursed into, because + # its decorator is kept on the original class and only its methods get + # trampolines, so reasons 1) and 3) above do not apply to it. + if isinstance(node, cst.Decorator): return True return False diff --git a/tests/mutation/test_mutation.py b/tests/mutation/test_mutation.py index e67786c0..f0900982 100644 --- a/tests/mutation/test_mutation.py +++ b/tests/mutation/test_mutation.py @@ -954,6 +954,24 @@ def x(self): assert not mutants +@pytest.mark.parametrize("decorator", ["@dataclass", "@dataclass(frozen=True)"]) +def test_mutate_methods_of_decorated_classes(decorator): + # The decorator of a class is not copied into the trampoline, so + # decorating a class must not stop its methods from being mutated + source = f""" +{decorator} +class Foo: + x: int + y: int + + def sum(self): + return self.x + self.y +""".strip() + + mutants = mutants_for_source(source) + assert mutants == [source.replace("self.x + self.y", "self.x - self.y")] + + def test_do_not_mutate_pattern_single_line(patch_config): source = 'logger.info("hello")' patch_config("do_not_mutate_patterns", [r"logger\.\w+\("])