From 4b1b53002e2e37edbbfbfa52ef9fdaa39ceded0b Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:56:50 -0700 Subject: [PATCH] fix: mutate methods of decorated classes MutationVisitor._skip_node_and_children skipped any decorated ClassDef and all of its children, so every method of a @dataclass (or any other decorated class) produced zero mutants. A class decorator stays on the original class definition - only methods get trampolines - so the reasons the skip exists (trampoline copying side effects, @property signatures) do not apply to classes. The skip is now on cst.Decorator itself, which keeps decorator arguments unmutated (e.g. @dataclass(frozen=True) does not become frozen=False) while allowing the class body to be mutated. Closes #480 --- src/mutmut/mutation/file_mutation.py | 7 ++++++- tests/mutation/test_mutation.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) 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+\("])