diff --git a/HISTORY.rst b/HISTORY.rst index e58c8593..f5761625 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,13 @@ Changelog --------- +Unreleased +~~~~~~~~~~ + +* Fix `mutate_only_covered_lines` mutating code that coverage.py excludes from measurement (`# pragma: no cover`, `exclude_lines`, `exclude_also`). Such lines are reported as covered when they run, so they used to produce mutants that could only ever survive + +* Fix mutations that delete ignored code. Dropping a `case` from a `match`, or an argument from a call, is a mutation of the enclosing node, so it used to be made even when the removed lines were themselves ignored + 3.7.0 ~~~~~ diff --git a/README.rst b/README.rst index 9b2ca2dc..69c91993 100644 --- a/README.rst +++ b/README.rst @@ -169,6 +169,11 @@ If you only want to mutate lines that are called (according to coverage.py), you mutate_only_covered_lines=true +This also honours the lines coverage.py is told to leave out of its measurement, so code +marked with `# pragma: no cover`, or matched by your `exclude_lines`/`exclude_also` +settings, is not mutated either. Such code is not held to your test suite, so mutants +there could only ever show up as survivors. + Filter generated mutants with type checker ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/e2e_projects/mutate_only_covered_lines/pyproject.toml b/e2e_projects/mutate_only_covered_lines/pyproject.toml index 0d1d866c..71456050 100644 --- a/e2e_projects/mutate_only_covered_lines/pyproject.toml +++ b/e2e_projects/mutate_only_covered_lines/pyproject.toml @@ -21,6 +21,12 @@ omit = [ "src/mutate_only_covered_lines/omit_me.py", ] +[tool.coverage.report] +exclude_also = [ + "if debug_only:", + "case _NEVER:", +] + [tool.mutmut] debug = true mutate_only_covered_lines = true diff --git a/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/exclude_me.py b/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/exclude_me.py new file mode 100644 index 00000000..d5c0e2f9 --- /dev/null +++ b/e2e_projects/mutate_only_covered_lines/src/mutate_only_covered_lines/exclude_me.py @@ -0,0 +1,42 @@ +"""Code that coverage.py is told not to measure. + +Every excluded region below is *executed* by the tests, so it ends up in the covered +lines. Only coverage.py's exclusion rules can keep it from being mutated. +""" + +debug_only = True + + +def excluded_function(a: int, b: int) -> int: # pragma: no cover + result = ( + a + + b + ) + return result + + +def excluded_branch(flag: bool) -> int: + if flag: # pragma: no cover + return 1 + 1 + return 2 + 2 + + +def excluded_by_coverage_config(a: int, b: int) -> int: + total = a * b + if debug_only: + total = ( + total + + 1 + ) + return total + + +def excluded_case(kind: str) -> int: + # dropping a case is a mutation of the `match`, not of the excluded lines themselves + match kind: + case "a": + return 1 + case "b": + return 2 + case _NEVER: + raise Exception("Can't happen") diff --git a/e2e_projects/mutate_only_covered_lines/tests/main/test_mutate_only_covered_lines.py b/e2e_projects/mutate_only_covered_lines/tests/main/test_mutate_only_covered_lines.py index 2fe08be7..3e68e49c 100644 --- a/e2e_projects/mutate_only_covered_lines/tests/main/test_mutate_only_covered_lines.py +++ b/e2e_projects/mutate_only_covered_lines/tests/main/test_mutate_only_covered_lines.py @@ -1,5 +1,6 @@ from mutate_only_covered_lines.ignore_me import this_function_shall_NOT_be_mutated from mutate_only_covered_lines import hello_mutate_only_covered_lines, mutate_only_covered_lines_multiline, function_with_pragma, do_not_mutate_external_ommited_function +from mutate_only_covered_lines.exclude_me import excluded_function, excluded_branch, excluded_by_coverage_config, excluded_case """This tests the mutate_only_covered_lines feature.""" @@ -17,3 +18,18 @@ def call_ignored_function(): def test_do_not_mutate_external_ommited_function(): assert do_not_mutate_external_ommited_function() == 7 + +def test_excluded_function(): + assert excluded_function(1, 2) == 3 + +def test_excluded_branch(): + # both branches run, so the excluded one is covered and only its exclusion can save it + assert excluded_branch(True) == 2 + assert excluded_branch(False) == 4 + +def test_excluded_by_coverage_config(): + assert excluded_by_coverage_config(2, 3) == 7 + +def test_excluded_case(): + assert excluded_case("a") == 1 + assert excluded_case("b") == 2 diff --git a/src/mutmut/__init__.py b/src/mutmut/__init__.py index 51026469..a956f4df 100644 --- a/src/mutmut/__init__.py +++ b/src/mutmut/__init__.py @@ -16,6 +16,7 @@ _stats: set[str] = set() _covered_lines: dict[str, set[int]] | None = None +_excluded_lines: dict[str, set[int]] | None = None def __getattr__(name: str) -> object: @@ -33,7 +34,7 @@ def __getattr__(name: str) -> object: def _reset_globals() -> None: global duration_by_test, stats_time, _stats, tests_by_mangled_function_name - global _covered_lines + global _covered_lines, _excluded_lines duration_by_test.clear() stats_time = None @@ -41,4 +42,5 @@ def _reset_globals() -> None: _stats = set() tests_by_mangled_function_name = defaultdict(set) _covered_lines = None + _excluded_lines = None reset_state() diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index 8a1bc80e..a96ceb75 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -60,6 +60,7 @@ import mutmut from mutmut.code_coverage import gather_coverage from mutmut.code_coverage import get_covered_lines_for_file +from mutmut.code_coverage import get_excluded_lines_for_file from mutmut.configuration import Config from mutmut.mutation.data import MutantLineSpans from mutmut.mutation.data import SourceFileMutationData @@ -278,7 +279,9 @@ def setup_source_paths() -> None: def store_lines_covered_by_tests() -> None: if Config.get().mutate_only_covered_lines: - mutmut._covered_lines = gather_coverage(PytestRunner(), list(walk_source_files())) + coverage_info = gather_coverage(PytestRunner(), list(walk_source_files())) + mutmut._covered_lines = coverage_info.covered_lines + mutmut._excluded_lines = coverage_info.excluded_lines def copy_also_copy_files() -> None: @@ -371,7 +374,10 @@ def create_mutants_for_file(filename: Path, output_path: Path) -> FileMutationRe def write_all_mutants_to_file(*, out: TextIOBase, source: str, filename: Path) -> MutatedFile: mutated_file = mutate_file_contents( - str(filename), source, get_covered_lines_for_file(str(filename), mutmut._covered_lines) + str(filename), + source, + get_covered_lines_for_file(str(filename), mutmut._covered_lines), + get_excluded_lines_for_file(str(filename), mutmut._excluded_lines), ) out.write(mutated_file.code) diff --git a/src/mutmut/code_coverage.py b/src/mutmut/code_coverage.py index bbf401b7..2684550c 100644 --- a/src/mutmut/code_coverage.py +++ b/src/mutmut/code_coverage.py @@ -3,17 +3,36 @@ import importlib import sys from collections.abc import Iterable +from dataclasses import dataclass +from dataclasses import field from pathlib import Path from types import ModuleType from typing import TYPE_CHECKING import coverage from coverage import CoverageData +from coverage.exceptions import CoverageException if TYPE_CHECKING: from mutmut.__main__ import TestRunner +@dataclass +class CoverageInfo: + """What coverage.py knows about the source files, keyed by absolute path in `mutants/`. + + `covered_lines` are the lines that actually executed. `excluded_lines` are the lines + coverage.py was told to ignore (`# pragma: no cover`, `exclude_lines`, `exclude_also`). + The two are kept apart rather than subtracted, because an excluded line that runs is + also a covered line, and the two are used differently: coverage.py reports only the + *first* line of each excluded statement, so those have to be expanded to full + statements before they mean anything to the mutation visitor. + """ + + covered_lines: dict[str, set[int]] = field(default_factory=dict) + excluded_lines: dict[str, set[int]] = field(default_factory=dict) + + # Returns a set of lines that are covered in this file gvein the covered_lines dict # returned by gather_coverage # None means it's not enabled, set() means no lines are covered @@ -29,11 +48,22 @@ def get_covered_lines_for_file(filename: str, covered_lines: dict[str, set[int]] return lines +# Returns the lines coverage.py excludes from measurement in this file, given the +# excluded_lines dict returned by gather_coverage. An empty set means nothing is excluded, +# which is also what we get when the feature is disabled. +def get_excluded_lines_for_file(filename: str, excluded_lines: dict[str, set[int]] | None) -> set[int]: + if excluded_lines is None or filename is None: + return set() + + abs_filename = str((Path("mutants") / filename).absolute()) + return set(excluded_lines.get(abs_filename, ())) + + # Gathers coverage for the given source files and -# Returns a dict of filenames to sets of lines that are covered +# Returns the covered and excluded lines of each of them # Since this is run on the source files before we create mutations, # we need to unload any modules that get loaded during the test run -def gather_coverage(runner: TestRunner, source_files: Iterable[Path]) -> dict[str, set[int]]: +def gather_coverage(runner: TestRunner, source_files: Iterable[Path]) -> CoverageInfo: # We want to unload any python modules that get loaded # because we plan to mutate them and want them to be reloaded modules = dict(sys.modules) @@ -47,17 +77,30 @@ def gather_coverage(runner: TestRunner, source_files: Iterable[Path]) -> dict[st # Build mapping of filenames to covered lines # The CoverageData object is a wrapper around sqlite, and this # will make it more efficient to access the data - covered_lines: dict[str, set[int]] = {} + info = CoverageInfo() coverage_data: CoverageData = cov.get_data() for filename in source_files: abs_filename = str((mutants_path / filename).absolute()) - lines = set(coverage_data.lines(abs_filename) or []) - covered_lines[abs_filename] = lines + info.covered_lines[abs_filename] = set(coverage_data.lines(abs_filename) or []) + info.excluded_lines[abs_filename] = _excluded_lines(cov, abs_filename) _unload_modules_not_in(modules) - return covered_lines + return info + + +# Asks coverage.py which lines of this file are excluded from measurement. +# This is analysis of the source, not of the collected data, so it is also +# correct for files the test run never imported. +def _excluded_lines(cov: coverage.Coverage, abs_filename: str) -> set[int]: + try: + _, _, excluded, _, _ = cov.analysis2(abs_filename) + except CoverageException: + # Unparseable or missing source: nothing we can say about it + return set() + + return set(excluded) # Unloads modules that are not in the 'modules' list diff --git a/src/mutmut/mutation/file_mutation.py b/src/mutmut/mutation/file_mutation.py index cc8b0d76..69fa158b 100644 --- a/src/mutmut/mutation/file_mutation.py +++ b/src/mutmut/mutation/file_mutation.py @@ -5,6 +5,7 @@ from collections import defaultdict from collections.abc import Callable from collections.abc import Iterable +from collections.abc import Iterator from collections.abc import Mapping from collections.abc import Sequence from dataclasses import dataclass @@ -99,9 +100,16 @@ class MutatedFile: hash_by_function_name: Mapping[str, str] -def mutate_file_contents(filename: str, code: str, covered_lines: set[int] | None = None) -> MutatedFile: +def mutate_file_contents( + filename: str, + code: str, + covered_lines: set[int] | None = None, + coverage_excluded_lines: set[int] | None = None, +) -> MutatedFile: """Create mutations for `code` and merge them to a single mutated file with trampolines.""" - module, mutations, ignored_classes, ignored_functions = create_mutations(filename, code, covered_lines) + module, mutations, ignored_classes, ignored_functions = create_mutations( + filename, code, covered_lines, coverage_excluded_lines + ) mutated_file = combine_mutations_to_source(module, mutations, ignored_classes, ignored_functions) @@ -109,18 +117,23 @@ def mutate_file_contents(filename: str, code: str, covered_lines: set[int] | Non def create_mutations( - filename: str, code: str, covered_lines: set[int] | None = None + filename: str, + code: str, + covered_lines: set[int] | None = None, + coverage_excluded_lines: set[int] | None = None, ) -> tuple[cst.Module, list[Mutation], set[str], set[str]]: """Parse the code and create mutations. :param filename: File path forwarded to :class:`PragmaVisitor` for error messages. :param code: Python source code to parse and mutate. :param covered_lines: If provided, only lines in this set are considered for mutation. + :param coverage_excluded_lines: Lines coverage.py excludes from measurement. The + statements starting on them are ignored, like a `# pragma: no mutate block`. :return: A tuple of (module, mutations, ignored_classes, ignored_functions).""" module = cst.parse_module(code) metadata_wrapper = MetadataWrapper(module) - ignored_code = get_ignored_lines(filename, code, metadata_wrapper) + ignored_code = get_ignored_lines(filename, code, metadata_wrapper, coverage_excluded_lines) visitor = MutationVisitor( mutation_operators, @@ -199,6 +212,9 @@ def __init__( self._covered_lines = covered_lines self._ignored_node_lines = ignored_code.ignore_node_lines self._ignored_pattern_lines = ignored_code.ignore_pattern_lines + self._all_ignored_lines = ( + ignored_code.no_mutate_lines | ignored_code.ignore_node_lines | ignored_code.ignore_pattern_lines + ) self.ignored_classes: set[str] = set() self.ignored_functions: set[str] = set() @@ -216,6 +232,8 @@ def _create_mutations(self, node: cst.CSTNode) -> None: for t, operator in self._operators: if isinstance(node, t): for mutated_node in operator(node): + if self._removes_ignored_code(node, mutated_node): + continue mutation = Mutation( original_node=node, mutated_node=mutated_node, @@ -223,6 +241,32 @@ def _create_mutations(self, node: cst.CSTNode) -> None: ) self.mutations.append(mutation) + def _removes_ignored_code(self, node: cst.CSTNode, mutated_node: cst.CSTNode) -> bool: + """Does this mutation delete code that we were told not to mutate? + + Some operators mutate by removing a part of a node: a `case` of a `match`, an + argument of a call. Such a mutation is anchored on the enclosing node, so the + line checks in `_should_mutate_node` never get to see the removed lines.""" + if not self._all_ignored_lines or not self._spans_ignored_line(node): + return False + + kept = {id(child) for child in _walk(mutated_node)} + for child in _walk(node): + if id(child) in kept: + continue + position = self.get_metadata(PositionProvider, child, None) + if position and position.start.line in self._all_ignored_lines: + return True + + return False + + def _spans_ignored_line(self, node: cst.CSTNode) -> bool: + position = self.get_metadata(PositionProvider, node, None) + if position is None: + return True + + return any(line in self._all_ignored_lines for line in range(position.start.line, position.end.line + 1)) + def _should_mutate_node(self, node: cst.CSTNode) -> bool: # currently, the position metadata does not always exist # (see https://github.com/Instagram/LibCST/issues/1322) @@ -295,6 +339,15 @@ def _skip_node_and_children(self, node: cst.CSTNode) -> bool: return False +def _walk(node: cst.CSTNode) -> Iterator[cst.CSTNode]: + """Yield `node` and every node below it.""" + stack = [node] + while stack: + current = stack.pop() + yield current + stack.extend(current.children) + + MODULE_STATEMENT = Union[cst.SimpleStatementLine, cst.BaseCompoundStatement] # convert str trampoline implementations to CST nodes with some whitespace diff --git a/src/mutmut/mutation/pragma_handling.py b/src/mutmut/mutation/pragma_handling.py index af67f2c9..13e8670b 100644 --- a/src/mutmut/mutation/pragma_handling.py +++ b/src/mutmut/mutation/pragma_handling.py @@ -21,19 +21,65 @@ class IgnoredCode: ignore_pattern_lines: set[int] -def get_ignored_lines(filename: str, source: str, metadata_wrapper: cst.MetadataWrapper) -> IgnoredCode: +def get_ignored_lines( + filename: str, + source: str, + metadata_wrapper: cst.MetadataWrapper, + coverage_excluded_lines: set[int] | None = None, +) -> IgnoredCode: pragma_visitor = PragmaVisitor(filename) metadata_wrapper.visit(pragma_visitor) lines_ignored_by_pattern = get_lines_ignored_by_pattern(source) + # Code excluded from coverage measurement can never be killed by the tests, so it is + # ignored exactly like a `# pragma: no mutate block` region. + ignore_node_lines = pragma_visitor.ignore_node_lines + if coverage_excluded_lines: + ignore_node_lines = ignore_node_lines | expand_to_full_statements(metadata_wrapper, coverage_excluded_lines) + return IgnoredCode( no_mutate_lines=pragma_visitor.no_mutate_lines, - ignore_node_lines=pragma_visitor.ignore_node_lines, + ignore_node_lines=ignore_node_lines, ignore_pattern_lines=lines_ignored_by_pattern, ) +def expand_to_full_statements(metadata_wrapper: cst.MetadataWrapper, lines: set[int]) -> set[int]: + """Grow each line in *lines* to cover every line of the statement starting there. + + coverage.py reports only the line a statement starts on, but a mutation can be + anchored on any line of a multi-line statement, so we need the whole span. The + original lines are always kept: not every one of them starts a statement, for + instance `case ...:`, `else:` and `except ...:` are clauses of one.""" + expander = StatementExpander(lines) + metadata_wrapper.visit(expander) + + return lines | expander.expanded_lines + + +class StatementExpander(cst.CSTVisitor): + """Collect the full line span of every statement that starts on one of `lines`. + + Only statements are expanded. A container such as the module or an indented block + starts on the same line as its first child, so expanding those would swallow the + code following the excluded part.""" + + METADATA_DEPENDENCIES = (PositionProvider,) + + def __init__(self, lines: set[int]) -> None: + self._lines = lines + self.expanded_lines: set[int] = set() + + def on_visit(self, node: cst.CSTNode) -> bool: + if isinstance(node, (cst.BaseStatement, cst.BaseSmallStatement)): + position = self.get_metadata(PositionProvider, node, None) + if position and position.start.line in self._lines: + self.expanded_lines.update(range(position.start.line, position.end.line + 1)) + + return True + + def get_lines_ignored_by_pattern(source: str) -> set[int]: matching_lines = set() for pattern in Config.get().do_not_mutate_patterns: diff --git a/tests/e2e/test_e2e_coverage.py b/tests/e2e/test_e2e_coverage.py index 112180cb..bb1b2f98 100644 --- a/tests/e2e/test_e2e_coverage.py +++ b/tests/e2e/test_e2e_coverage.py @@ -43,6 +43,21 @@ def test_mutate_only_covered_lines_result_snapshot(): "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_31": 1, "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_32": 1, }, + "mutants/src/mutate_only_covered_lines/exclude_me.py.meta": { + "mutate_only_covered_lines.exclude_me.x_excluded_branch__mutmut_1": 1, + "mutate_only_covered_lines.exclude_me.x_excluded_branch__mutmut_2": 1, + "mutate_only_covered_lines.exclude_me.x_excluded_branch__mutmut_3": 1, + "mutate_only_covered_lines.exclude_me.x_excluded_by_coverage_config__mutmut_1": 1, + "mutate_only_covered_lines.exclude_me.x_excluded_by_coverage_config__mutmut_2": 1, + "mutate_only_covered_lines.exclude_me.x_excluded_case__mutmut_1": 1, + "mutate_only_covered_lines.exclude_me.x_excluded_case__mutmut_2": 1, + "mutate_only_covered_lines.exclude_me.x_excluded_case__mutmut_3": 1, + "mutate_only_covered_lines.exclude_me.x_excluded_case__mutmut_4": 1, + "mutate_only_covered_lines.exclude_me.x_excluded_case__mutmut_5": 1, + "mutate_only_covered_lines.exclude_me.x_excluded_case__mutmut_6": 1, + "mutate_only_covered_lines.exclude_me.x_excluded_case__mutmut_7": 1, + "mutate_only_covered_lines.exclude_me.x_excluded_case__mutmut_8": 1, + }, "mutants/src/mutate_only_covered_lines/omit_me.py.meta": {}, } ) diff --git a/tests/mutation/test_mutation.py b/tests/mutation/test_mutation.py index b5525986..8bcf0181 100644 --- a/tests/mutation/test_mutation.py +++ b/tests/mutation/test_mutation.py @@ -45,8 +45,12 @@ from mutmut.utils.format_utils import get_mutant_name -def mutants_for_source(source: str, covered_lines: set[int] | None = None) -> list[str]: - module, mutated_nodes, _, _ = create_mutations("test.py", source, covered_lines) +def mutants_for_source( + source: str, + covered_lines: set[int] | None = None, + coverage_excluded_lines: set[int] | None = None, +) -> list[str]: + module, mutated_nodes, _, _ = create_mutations("test.py", source, covered_lines, coverage_excluded_lines) mutants: list[str] = [module.deep_replace(m.original_node, m.mutated_node).code for m in mutated_nodes] # type: ignore return mutants @@ -692,6 +696,95 @@ def test_mutate_only_covered_lines_all(): assert mutants == mutants_expected +def test_coverage_excluded_lines_are_not_mutated(): + source = """def foo():\n return 1+1\n""".strip() + assert mutants_for_source(source, coverage_excluded_lines=set([2])) == [] + + +def test_coverage_excluded_lines_do_not_affect_other_lines(): + source = "def foo():\n return 1 + 1\ndef bar():\n return 2 + 2" + + module, mutations, _, ignored_functions = create_mutations("test.py", source, None, set([1, 2])) + + mutants = [module.deep_replace(m.original_node, m.mutated_node).code for m in mutations] # type: ignore + assert ignored_functions == {"foo"} + assert all("2 + 2" not in mutant for mutant in mutants) + assert all("1 + 1" in mutant for mutant in mutants) + + +def test_coverage_excluded_lines_cover_whole_multiline_statement(): + # coverage.py reports only the *first* line of an excluded statement, so line 2 alone + # stands for the whole assignment and has to be expanded to reach the operands on the + # continuation lines. + source = "def foo(a, b):\n result = (\n a\n + b\n )\n return result" + + assert any("a - b" in mutant for mutant in mutants_for_source(source)) + assert mutants_for_source(source, coverage_excluded_lines=set([2])) == [] + + +def test_coverage_excluded_lines_do_not_swallow_the_following_statement(): + # The `if` body and the statement after it start on adjacent lines, and the enclosing + # indented block starts on the same line as the `if`. Expanding the exclusion must + # follow the excluded statement only, not its container. + source = "def foo(flag):\n if flag:\n return 1 + 1\n return 2 + 2" + + mutants = mutants_for_source(source, coverage_excluded_lines=set([2, 3])) + + assert mutants + assert all("1 + 1" in mutant for mutant in mutants) + assert any("2 - 2" in mutant for mutant in mutants) + + +def test_coverage_excluded_class_is_not_mutated(): + source = "class C:\n def m(self):\n return 1 + 1" + + _, mutations, ignored_classes, _ = create_mutations("test.py", source, None, set([1, 2, 3])) + + assert ignored_classes == {"C"} + assert mutations == [] + + +def test_coverage_excluded_case_is_not_dropped_from_match(): + # Dropping a case is a mutation of the enclosing `match`, so the excluded lines of + # the case itself are never looked at unless the removal is checked separately. + source = 'def foo(tok):\n match tok:\n case 1:\n return 10\n case _:\n raise Exception("nope")' + + mutants = mutants_for_source(source, coverage_excluded_lines=set([5, 6])) + + match_drops = [mutant for mutant in mutants if "match tok:" in mutant] + assert match_drops + assert all("case _:" in mutant for mutant in match_drops) + + +def test_coverage_excluded_argument_is_not_removed_from_call(): + source = "def foo(a, b):\n return g(\n a,\n b,\n )" + + mutants = mutants_for_source(source, coverage_excluded_lines=set([3])) + + assert mutants + assert all("a," in mutant for mutant in mutants) + + +def test_coverage_excluded_case_keeps_the_other_cases_droppable(): + source = 'def foo(tok):\n match tok:\n case 1:\n return 10\n case 2:\n return 20\n case _:\n raise Exception("nope")' + + def match_drops(coverage_excluded_lines): + _, mutations, _, _ = create_mutations("test.py", source, None, coverage_excluded_lines) + return [m for m in mutations if isinstance(m.original_node, cst.Match)] + + # one drop per case, minus the excluded one: the match is still worth mutating + assert len(match_drops(None)) == 3 + assert len(match_drops(set([7, 8]))) == 2 + + +def test_coverage_excluded_first_line_does_not_skip_whole_module(): + # The module node starts on line 1 just like a statement excluded there does, and it + # spans the whole file, so it must not be expanded along with the statement. + source = "import os\ndef foo():\n return 1 + 1" + + assert mutants_for_source(source, coverage_excluded_lines=set([1])) + + def test_mutate_dict(): source = "dict(a=b, c=d)" diff --git a/tests/mutation/test_pragma_handling.py b/tests/mutation/test_pragma_handling.py index 081bfd5c..4b80591e 100644 --- a/tests/mutation/test_pragma_handling.py +++ b/tests/mutation/test_pragma_handling.py @@ -15,6 +15,12 @@ def _parse_ignored_code(filename: str, source: str) -> IgnoredCode: return get_ignored_lines(filename, source, wrapper) +def _parse_ignored_code_with_exclusions(filename: str, source: str, excluded_lines: set[int]) -> IgnoredCode: + module = cst.parse_module(source) + wrapper = MetadataWrapper(module) + return get_ignored_lines(filename, source, wrapper, excluded_lines) + + class TestParsePragmaLines: """Tests for PragmaVisitor basic pragma detection.""" @@ -388,3 +394,39 @@ def foo(): patch_config("do_not_mutate_patterns", [r"logger\.\w+\("]) ignored_code = _parse_ignored_code("test.py", source) assert ignored_code.ignore_pattern_lines == {3} + + +class TestCoverageExcludedLines: + """Tests for folding coverage.py's excluded lines into the ignored ones.""" + + def test_multiline_statement_is_expanded(self): + source = """ +def foo(a, b): + result = ( + a + + b + ) +""" + ignored_code = _parse_ignored_code_with_exclusions("test.py", source, {3}) + assert ignored_code.ignore_node_lines == {3, 4, 5, 6} + + def test_line_that_does_not_start_a_statement_is_kept(self): + # `case ...:` is a clause of the match statement, not a statement of its own, so + # there is nothing to expand and the line has to survive on its own. + source = """ +def foo(tok): + match tok: + case 1: + return 10 + case _: + raise Exception("nope") +""" + ignored_code = _parse_ignored_code_with_exclusions("test.py", source, {6, 7}) + assert ignored_code.ignore_node_lines == {6, 7} + + def test_module_is_not_expanded(self): + source = "import os\ndef foo():\n return 1 + 1" + + ignored_code = _parse_ignored_code_with_exclusions("test.py", source, {1}) + + assert ignored_code.ignore_node_lines == {1} diff --git a/tests/test_code_coverage.py b/tests/test_code_coverage.py new file mode 100644 index 00000000..b3a357d5 --- /dev/null +++ b/tests/test_code_coverage.py @@ -0,0 +1,51 @@ +import coverage + +from mutmut.code_coverage import _excluded_lines +from mutmut.code_coverage import get_excluded_lines_for_file + + +def test_get_excluded_lines_for_file_without_data(): + assert get_excluded_lines_for_file("foo.py", None) == set() + + +def test_get_excluded_lines_for_file_unknown_file(): + assert get_excluded_lines_for_file("foo.py", {}) == set() + + +def test_excluded_lines_of_unreadable_file(tmp_path): + assert _excluded_lines(coverage.Coverage(data_file=None), str(tmp_path / "does_not_exist.py")) == set() + + +def test_excluded_lines_reports_only_the_first_line_of_each_statement(tmp_path): + # coverage.py only reports the line a statement *starts* on, which is why the + # excluded lines have to be expanded to full statements before they are of any use. + # If that ever changes, the expansion is doing unnecessary work. + source_file = tmp_path / "excluded.py" + source_file.write_text( + "def keep(a, b):\n" + " return (\n" + " a\n" + " + b\n" + " )\n" + "\n" + "def drop(a, b): # pragma: no cover\n" + " return (\n" + " a\n" + " + b\n" + " )\n" + ) + + excluded = _excluded_lines(coverage.Coverage(data_file=None), str(source_file)) + + assert excluded == {7, 8} + + +def test_excluded_lines_honours_the_projects_coverage_config(tmp_path, monkeypatch): + (tmp_path / ".coveragerc").write_text("[report]\nexclude_also =\n if not_tested:\n") + source_file = tmp_path / "configured.py" + source_file.write_text("def foo(not_tested):\n if not_tested:\n return 1 + 1\n return 2 + 2\n") + + monkeypatch.chdir(tmp_path) + excluded = _excluded_lines(coverage.Coverage(data_file=None), str(source_file)) + + assert excluded == {2, 3}