Skip to content

Commit 1d85191

Browse files
committed
implement assertion minimization
1 parent bcfe27b commit 1d85191

5 files changed

Lines changed: 323 additions & 2 deletions

File tree

src/pynguin/assertion/assertiongenerator.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,59 @@ def get_score(self) -> float:
223223
return self.num_killed_mutants / divisor
224224

225225

226+
def _select_minimal_assertions(
227+
kill_map: dict[tuple[int, int], set[int]],
228+
) -> set[tuple[int, int]]:
229+
"""Greedy set-cover selection of a minimal assertion subset.
230+
231+
Given a mapping from assertion key ``(stmt_idx, assertion_idx)`` to the set of
232+
mutant indices that assertion kills, return the subset of keys that together
233+
cover every killed mutant (the union of all kill sets). Assertions with an empty
234+
kill set are never selected. Ties are broken by ascending key (insertion order)
235+
for deterministic output.
236+
237+
Args:
238+
kill_map: Mapping from assertion key to the set of mutants it kills.
239+
240+
Returns:
241+
The set of assertion keys to keep.
242+
"""
243+
universe: set[int] = set()
244+
for kills in kill_map.values():
245+
universe |= kills
246+
247+
# Only assertions that kill at least one mutant can ever be selected.
248+
candidates = {key: kills for key, kills in kill_map.items() if kills}
249+
250+
uncovered = set(universe)
251+
keep: set[tuple[int, int]] = set()
252+
while uncovered:
253+
best_key: tuple[int, int] | None = None
254+
best_cover = 0
255+
for key in sorted(candidates):
256+
cover = len(candidates[key] & uncovered)
257+
if cover > best_cover:
258+
best_cover = cover
259+
best_key = key
260+
if best_key is None:
261+
break
262+
keep.add(best_key)
263+
uncovered -= candidates[best_key]
264+
del candidates[best_key]
265+
266+
# Greedy may leave a selection whose kills are fully covered by the others it
267+
# later picked. Prune such redundant assertions, dropping higher keys first so
268+
# lower (earlier) assertions are preferred, keeping coverage unchanged.
269+
for key in sorted(keep, reverse=True):
270+
others: set[int] = set()
271+
for other in keep:
272+
if other != key:
273+
others |= kill_map[other]
274+
if kill_map[key] <= others:
275+
keep.discard(key)
276+
return keep
277+
278+
226279
class MutationAnalysisAssertionGenerator(AssertionGenerator):
227280
"""Uses mutation analysis to filter out less relevant assertions."""
228281

@@ -381,6 +434,11 @@ def __remove_non_relevant_assertions(
381434
tests_mutants_results: list[list[ex.ExecutionResult | None]],
382435
mutation_summary: _MutationSummary,
383436
) -> None:
437+
if config.configuration.test_case_output.assertion_minimization:
438+
MutationAnalysisAssertionGenerator.__minimize_assertions(
439+
test_cases, tests_mutants_results, mutation_summary
440+
)
441+
return
384442
for test, results in zip(test_cases, tests_mutants_results, strict=True):
385443
merged = at.AssertionVerificationTrace()
386444
for result, mut in zip(results, mutation_summary.mutant_information, strict=True):
@@ -392,6 +450,77 @@ def __remove_non_relevant_assertions(
392450
if not merged.was_violated(stmt_idx, assertion_idx):
393451
statement.assertions.remove(assertion)
394452

453+
@staticmethod
454+
def __minimize_assertions(
455+
test_cases: list[tc.TestCase],
456+
tests_mutants_results: list[list[ex.ExecutionResult | None]],
457+
mutation_summary: _MutationSummary,
458+
) -> None:
459+
"""Keep a minimal subset of assertions preserving the killed mutants.
460+
461+
For each test case, a greedy set-cover selection keeps only enough
462+
assertions to preserve the full set of assertion-attributable mutant kills.
463+
Assertions that kill nothing are dropped (subsuming the plain
464+
non-relevant-assertion removal); redundant assertions that only re-kill
465+
mutants already covered by kept assertions are removed as well.
466+
467+
Statements carrying only an exception assertion are left untouched to
468+
avoid the fragility of mixing exception and value assertions.
469+
470+
Args:
471+
test_cases: The test cases whose assertions are minimized.
472+
tests_mutants_results: Per-test, per-mutant execution results.
473+
mutation_summary: Summary with per-mutant timeout information.
474+
"""
475+
for test, results in zip(test_cases, tests_mutants_results, strict=True):
476+
kill_map = MutationAnalysisAssertionGenerator.__build_kill_map(
477+
test, results, mutation_summary
478+
)
479+
keep = _select_minimal_assertions(kill_map)
480+
481+
for stmt_idx, statement in enumerate(test.statements):
482+
if statement.has_only_exception_assertion():
483+
continue
484+
for assertion_idx, assertion in reversed(list(enumerate(statement.assertions))):
485+
if (stmt_idx, assertion_idx) not in keep:
486+
statement.assertions.remove(assertion)
487+
488+
@staticmethod
489+
def __build_kill_map(
490+
test: tc.TestCase,
491+
results: list[ex.ExecutionResult | None],
492+
mutation_summary: _MutationSummary,
493+
) -> dict[tuple[int, int], set[int]]:
494+
"""Map each assertion to the set of mutants it kills via violation.
495+
496+
Statements carrying only an exception assertion are skipped. Only
497+
assertion-attributable kills on non-timed-out mutants are counted.
498+
499+
Args:
500+
test: The test case whose assertions are inspected.
501+
results: Per-mutant execution results for this test.
502+
mutation_summary: Summary with per-mutant timeout information.
503+
504+
Returns:
505+
Mapping from ``(stmt_idx, assertion_idx)`` to the killed mutant indices.
506+
"""
507+
kill_map: dict[tuple[int, int], set[int]] = {}
508+
for stmt_idx, statement in enumerate(test.statements):
509+
if statement.has_only_exception_assertion():
510+
continue
511+
for assertion_idx in range(len(statement.assertions)):
512+
kills = {
513+
mutant_idx
514+
for mutant_idx, (result, mut) in enumerate(
515+
zip(results, mutation_summary.mutant_information, strict=True)
516+
)
517+
if result is not None
518+
and len(mut.timed_out_by) == 0
519+
and result.assertion_verification_trace.was_violated(stmt_idx, assertion_idx)
520+
}
521+
kill_map[stmt_idx, assertion_idx] = kills
522+
return kill_map
523+
395524
@staticmethod
396525
def __compute_mutation_summary(
397526
number_of_mutants: int,

src/pynguin/configuration.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,10 @@ class TestCaseOutputConfiguration:
367367
marking the test with @pytest.mark.xfail(strict=True). Use this for better mutation
368368
scores."""
369369

370+
assertion_minimization: bool = True
371+
"""If enabled, remove redundant assertions after mutation analysis, keeping a
372+
minimal subset that preserves the set of killed mutants (greedy set cover)."""
373+
370374

371375
@dataclasses.dataclass
372376
class SeedingConfiguration:

tests/assertion/test_assertion_generation_integration.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,114 @@ def test_mutation_analysis_truncated_by_time_budget(subject_properties: SubjectP
490490
assert len(threading.enumerate()) == 1
491491

492492

493+
@pytest.mark.parametrize(
494+
"module,test_case_str,test_case_str_with_assertions,killed,timeout",
495+
[
496+
# Value assertion that kills mutants is kept.
497+
(
498+
"tests.fixtures.mutation.mutation",
499+
"def test_case_0():\n int_0 = 0\n int_1 = 0\n int_2 = 0\n"
500+
" int_3 = 1\n float_0 = module_0.foo(int_3)",
501+
"int_0 = 0\nint_1 = 0\nint_2 = 0\nint_3 = 1\nfloat_0 = "
502+
"module_0.foo(int_3)\n"
503+
"assert float_0 == pytest.approx(2.0, abs=0.01, rel=0.01)",
504+
{0, 1, 3, 4},
505+
set(),
506+
),
507+
# Assertion that kills nothing is dropped (subsumes kills-nothing removal).
508+
(
509+
"tests.fixtures.mutation.mutation",
510+
"def test_case_0():\n int_0 = 0\n int_1 = 0\n int_2 = 0\n"
511+
" int_3 = 0\n float_0 = module_0.foo(int_3)",
512+
"int_0 = 0\nint_1 = 0\nint_2 = 0\nint_3 = 0\nmodule_0.foo(int_3)",
513+
set(),
514+
set(),
515+
),
516+
# Statement with only an exception assertion is left untouched.
517+
(
518+
"tests.fixtures.mutation.exception",
519+
"def test_case_0():\n float_0 = module_0.foo()",
520+
"module_0.foo()",
521+
{0, 3, 4},
522+
set(),
523+
),
524+
# Expected-exception (pytest.raises) case is preserved.
525+
(
526+
"tests.fixtures.mutation.expected",
527+
"def test_case_0():\n int_0 = 2\n var_0 = module_0.bar(int_0)",
528+
"int_0 = 2\nwith pytest.raises(ValueError):\n module_0.bar(int_0)",
529+
{0, 1, 2},
530+
set(),
531+
),
532+
],
533+
)
534+
def test_mutation_analysis_minimization_preserves_output( # noqa: PLR0914, PLR0917
535+
module,
536+
test_case_str,
537+
test_case_str_with_assertions,
538+
killed,
539+
timeout,
540+
subject_properties: SubjectProperties,
541+
):
542+
"""With minimization on, already-minimal outputs are reproduced exactly.
543+
544+
These fixtures produce assertions that are either uniquely required or kill
545+
nothing, so greedy set cover must keep exactly the same assertions as the
546+
default path while preserving the killed mutants.
547+
"""
548+
original_minimization = config.configuration.test_case_output.assertion_minimization
549+
config.configuration.test_case_output.assertion_minimization = True
550+
try:
551+
config.configuration.module_name = module
552+
module_name = config.configuration.module_name
553+
with install_import_hook(module_name, subject_properties):
554+
with subject_properties.instrumentation_tracer:
555+
module_type = importlib.import_module(module_name)
556+
importlib.reload(module_type)
557+
cluster = generate_test_cluster(module_name)
558+
transformer = AstToTestCaseTransformer(
559+
cluster,
560+
False, # noqa: FBT003
561+
EmptyConstantProvider(),
562+
)
563+
transformer.visit(ast.parse(test_case_str))
564+
test_case = transformer.testcases[0]
565+
566+
chromosome = tcc.TestCaseChromosome(test_case)
567+
suite = tsc.TestSuiteChromosome()
568+
suite.add_test_case_chromosome(chromosome)
569+
570+
mutant_generator = mu.FirstOrderMutator([
571+
*test_operators,
572+
mo.OneIterationLoop,
573+
mo.ReverseIterationLoop,
574+
mo.ZeroIterationLoop,
575+
])
576+
module_source_code = inspect.getsource(module_type)
577+
module_ast = ParentNodeTransformer.create_ast(module_source_code)
578+
mutation_controller = MutationController(mutant_generator, module_ast, module_type)
579+
gen = ag.MutationAnalysisAssertionGenerator(
580+
TestCaseExecutor(subject_properties), mutation_controller, testing=True
581+
)
582+
suite.accept(gen)
583+
584+
summary = gen._testing_mutation_summary
585+
assert {k.mut_num for k in summary.get_killed()} == killed
586+
assert {k.mut_num for k in summary.get_timeout()} == timeout
587+
588+
visitor = tc_to_ast.TestCaseToAstVisitor(ns.NamingScope(prefix="module"), set())
589+
test_case.accept(visitor)
590+
source = ast.unparse(
591+
ast.fix_missing_locations(ast.Module(body=visitor.test_case_ast, type_ignores=[]))
592+
)
593+
assert source == test_case_str_with_assertions
594+
for thread in threading.enumerate():
595+
if "_execute_test_case" in thread.name:
596+
thread.join()
597+
finally:
598+
config.configuration.test_case_output.assertion_minimization = original_minimization
599+
600+
493601
def _add_assertions(module_name: str, test_case_code: str) -> str:
494602
"""Add assertions to a test case and return the test case with assertions.
495603

tests/assertion/test_assertiongenerator.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
# SPDX-License-Identifier: MIT
66
#
77
import pytest
8+
from hypothesis import given
9+
from hypothesis import strategies as st
810

911
import pynguin.assertion.assertiongenerator as ag
1012

@@ -77,3 +79,78 @@ def test_score_excludes_unchecked_mutants():
7779
)
7880
def test_compute_metrics(inp, result):
7981
assert ag._MutationSummary(inp).get_metrics() == result
82+
83+
84+
def test_select_minimal_assertions_empty():
85+
assert ag._select_minimal_assertions({}) == set()
86+
87+
88+
def test_select_minimal_assertions_drops_assertion_killing_nothing():
89+
kill_map = {(0, 0): {1, 2}, (0, 1): set()}
90+
assert ag._select_minimal_assertions(kill_map) == {(0, 0)}
91+
92+
93+
def test_select_minimal_assertions_identical_kills_keeps_one():
94+
# Two assertions killing exactly the same mutants -> keep exactly one.
95+
kill_map = {(0, 0): {1, 2}, (0, 1): {1, 2}}
96+
assert ag._select_minimal_assertions(kill_map) == {(0, 0)}
97+
98+
99+
def test_select_minimal_assertions_subset_dropped():
100+
# (0, 1) kills a subset of what (0, 0) kills -> only the superset is kept.
101+
kill_map = {(0, 0): {1, 2, 3}, (0, 1): {2}}
102+
assert ag._select_minimal_assertions(kill_map) == {(0, 0)}
103+
104+
105+
def test_select_minimal_assertions_disjoint_keeps_both():
106+
kill_map = {(0, 0): {1}, (0, 1): {2}}
107+
assert ag._select_minimal_assertions(kill_map) == {(0, 0), (0, 1)}
108+
109+
110+
def test_select_minimal_assertions_tie_break_lowest_index():
111+
# Both cover the whole universe -> the lowest key is chosen deterministically.
112+
kill_map = {(1, 0): {1, 2}, (0, 0): {1, 2}}
113+
assert ag._select_minimal_assertions(kill_map) == {(0, 0)}
114+
115+
116+
def test_select_minimal_assertions_covers_universe():
117+
kill_map = {(0, 0): {1, 2}, (0, 1): {2, 3}, (0, 2): {4}}
118+
keep = ag._select_minimal_assertions(kill_map)
119+
covered: set[int] = set()
120+
for key in keep:
121+
covered |= kill_map[key]
122+
assert covered == {1, 2, 3, 4}
123+
124+
125+
@given(
126+
st.dictionaries(
127+
keys=st.tuples(
128+
st.integers(min_value=0, max_value=5), st.integers(min_value=0, max_value=5)
129+
),
130+
values=st.sets(st.integers(min_value=0, max_value=20), max_size=8),
131+
max_size=12,
132+
)
133+
)
134+
def test_select_minimal_assertions_invariants(kill_map):
135+
keep = ag._select_minimal_assertions(kill_map)
136+
137+
universe: set[int] = set()
138+
for kills in kill_map.values():
139+
universe |= kills
140+
141+
# Mutation score preserved: the kept assertions cover the whole universe.
142+
covered: set[int] = set()
143+
for key in keep:
144+
covered |= kill_map[key]
145+
assert covered == universe
146+
147+
# Only kill-bearing assertions are ever kept.
148+
assert all(kill_map[key] for key in keep)
149+
150+
# Minimality: dropping any kept assertion loses coverage.
151+
for key in keep:
152+
others: set[int] = set()
153+
for other in keep:
154+
if other != key:
155+
others |= kill_map[other]
156+
assert not kill_map[key] <= others

tests/utils/test_configuration_writer.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def expected_toml(tmp_path):
5151
float_precision = 0.01
5252
format_with_black = true
5353
no_xfail = false
54+
assertion_minimization = true
5455
5556
[statistics_output]
5657
report_dir = "{REPORT_DIR}"
@@ -254,8 +255,8 @@ def expected_txt(tmp_path):
254255
'maximum_mutants=-1, post_process=True, '
255256
'minimization=Minimization(test_case_minimization_strategy=<MinimizationStrategy.CASE: '
256257
"'CASE'>, test_case_minimization_direction=<MinimizationDirection.BACKWARD: "
257-
"'BACKWARD'>), float_precision=0.01, format_with_black=True, no_xfail=False), "
258-
"algorithm=<Algorithm.RANDOM: 'RANDOM'>, "
258+
"'BACKWARD'>), float_precision=0.01, format_with_black=True, no_xfail=False, "
259+
"assertion_minimization=True), algorithm=<Algorithm.RANDOM: 'RANDOM'>, "
259260
"statistics_output=StatisticsOutputConfiguration(report_dir='{REPORT_DIR}', "
260261
"statistics_backend=<StatisticsBackend.CSV: 'CSV'>, "
261262
'timeline_interval=1000000000, timeline_interpolation=True, '
@@ -574,6 +575,8 @@ def expected_parameters() -> str:
574575
False
575576
--test_case_output.assertion_generation
576577
MUTATION_ANALYSIS
578+
--test_case_output.assertion_minimization
579+
True
577580
--test_case_output.export_strategy
578581
PY_TEST
579582
--test_case_output.float_precision

0 commit comments

Comments
 (0)