Skip to content

@dataclass methods produce zero mutants #480

@tomwillis608

Description

@tomwillis608

@dataclass methods produce zero mutants

Let me start by thanking you for this great asset!
And it could be that we are using this incorrectly.

Summary

mutate_file_contents generates zero mutants for methods defined inside a @dataclass class, while identical methods in a plain class produce mutants as expected.

Environment

  • mutmut 3.5.0
  • Python 3.11.14
  • Linux (WSL2)

Minimal reproduction

# repro.py
from mutmut.file_mutation import mutate_file_contents

PLAIN_CLASS = """
class Foo:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def sum(self):
        return self.x + self.y
"""

DATACLASS = """
from dataclasses import dataclass

@dataclass
class Foo:
    x: int
    y: int

    def sum(self):
        return self.x + self.y
"""

FROZEN_DATACLASS = """
from dataclasses import dataclass

@dataclass(frozen=True)
class Foo:
    x: int
    y: int

    def sum(self):
        return self.x + self.y
"""

for label, code in [
    ("plain class", PLAIN_CLASS),
    ("@dataclass", DATACLASS),
    ("@dataclass(frozen=True)", FROZEN_DATACLASS),
]:
    _, names = mutate_file_contents("foo.py", code)
    print(f"{label}: {len(names)} mutants  {names}")

Actual output

plain class: 3 mutants  ['xǁFooǁ__init____mutmut_1', 'xǁFooǁ__init____mutmut_2', 'xǁFooǁsum__mutmut_1']
@dataclass: 0 mutants  []
@dataclass(frozen=True): 0 mutants  []

Expected output

All three cases should produce at least one mutant for sum (e.g. self.x + self.y -> self.x - self.y).

Root cause

In file_mutation.py, MutationVisitor._skip_node_and_children (line 138) has a blanket skip for any decorated class or function:

# line 154-159
        # ignore decorated functions, because
        # 1) copying them for the trampoline setup can cause side effects (e.g. multiple @app.post("/foo") definitions)
        # 2) decorators are executed when the function is defined, so we don't want to mutate their arguments and cause exceptions
        # 3) @property decorators break the trampoline signature assignment (which expects it to be a function)
        if isinstance(node, (cst.FunctionDef, cst.ClassDef)) and len(node.decorators):
            return True

The three reasons in the comment are valid for @app.post, @property, @staticmethod,
etc. — but @dataclass doesn't appear to those problems in this case.

  1. No side effects from re-execution (it just defines a class)
  2. Decorator arguments aren't in method bodies
  3. Doesn't change method signatures the way @property does

Workaround we're using

We patched our local mutmut install with a safe-decorator allowlist. The change is small — a helper function and a three-line guard added before the existing return True:

SAFE_CLASS_DECORATORS = {"dataclass"}

def _is_safe_class_decorator(decorator: cst.Decorator) -> bool:
    """Check if a decorator is safe to recurse into (won't break trampolines)."""
    dec = decorator.decorator
    # @dataclass
    if isinstance(dec, cst.Name) and dec.value in SAFE_CLASS_DECORATORS:
        return True
    # @dataclass(frozen=True) etc.
    if isinstance(dec, cst.Call) and isinstance(dec.func, cst.Name) and dec.func.value in SAFE_CLASS_DECORATORS:
        return True
    return False

Then in _skip_node_and_children:

        if isinstance(node, (cst.FunctionDef, cst.ClassDef)) and len(node.decorators):
            if isinstance(node, cst.ClassDef) and all(
                _is_safe_class_decorator(d) for d in node.decorators
            ):
                return False  # safe — recurse into methods
            return True

We tested this against a real @dataclass(frozen=True) entity with a __post_init__ method — 20 mutants generated and all 20 killed by existing tests.

We plan to maintain this in a fork for our CI pipeline. If this looks like something you'd accept as a PR, we're happy to contribute it upstream with tests. No rush — just let us know.

Relationship to #302

#302 was about type hint mutations (|&) in mutmut v2 and was closed when v3 shipped. This is a different issue introduced by the v3 rewrite: the blanket decorator skip in _skip_node_and_children prevents mutation of all code inside any decorated class, not just type annotations.

In #302, @boxed noted: "I think there is a case for SOME type hints to be mutated though, like for dataclasses or serializers." — which suggests dataclass method bodies were always intended to be mutable.

Notes

  • The trampoline boilerplate (_mutmut_trampoline, MutantDict, etc.) is still injected into the output for the dataclass cases — only the per-method mutant variants are missing.
  • Tested with both @dataclass and @dataclass(frozen=True) — same result.
  • Standalone functions in the same file are mutated; only methods inside @dataclass-decorated classes are skipped.
  • mutmut itself uses @dataclass in file_mutation.py line 16.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions