diff --git a/src/pynguin/testcase/testfactory.py b/src/pynguin/testcase/testfactory.py index 8d24601cf..5420adee8 100644 --- a/src/pynguin/testcase/testfactory.py +++ b/src/pynguin/testcase/testfactory.py @@ -349,6 +349,9 @@ def __init__( if constant_provider is None: constant_provider = EmptyConstantProvider() self._constant_provider: ConstantProvider = constant_provider + # Smallest depth at which creating a type failed, kept only while the + # outermost _create_var_of_type call is running. + self._failed_creation_depths: dict[ProperType, int] | None = None @property def test_cluster(self) -> ModuleTestCluster: @@ -2804,22 +2807,36 @@ def _create_var_of_type( """ if depth >= config.configuration.test_creation.max_recursion: return None, position + # All candidate generators are tried in turn, so a type that no generator can + # build, e.g., an abstract class whose generators need an instance of itself, + # would be retried exponentially often. With the same or less depth left, a + # failed type fails again, so remember failures within the outermost call. + failed_depths = self._failed_creation_depths + if failed_depths is not None and depth >= failed_depths.get(param_type, depth + 1): + return None, position generators = list(self._test_cluster.get_generators_for(param_type)) if not generators: return None, position - for gen in self._select_candidate_generators(param_type, generators): - pre_size = test_case.size() - new_pos = self._emit_accessible(test_case, gen, position, depth + 1) - if new_pos < 0: - continue - total_added = test_case.size() - pre_size - matching_var = self._find_matching_variable( - test_case, position, total_added, raw, new_pos - ) - return matching_var, position + total_added - - return None, position + outermost = failed_depths is None + if failed_depths is None: + failed_depths = self._failed_creation_depths = {} + try: + for gen in self._select_candidate_generators(param_type, generators): + pre_size = test_case.size() + new_pos = self._emit_accessible(test_case, gen, position, depth + 1) + if new_pos < 0: + continue + total_added = test_case.size() - pre_size + matching_var = self._find_matching_variable( + test_case, position, total_added, raw, new_pos + ) + return matching_var, position + total_added + failed_depths[param_type] = depth + return None, position + finally: + if outermost: + self._failed_creation_depths = None @staticmethod def _find_any_variable(test_case: tc.TestCase, position: int) -> str | None: diff --git a/tests/analyses/test_subtypes.py b/tests/analyses/test_subtypes.py index 61e9f6178..ec1c786dd 100644 --- a/tests/analyses/test_subtypes.py +++ b/tests/analyses/test_subtypes.py @@ -21,6 +21,7 @@ GenericConstructor, ) from tests.fixtures.examples.abstract_classes import Animal, Bird, Circle, Shape +from tests.fixtures.examples.abstract_self_generators import Expression def test_abstract_class_has_subclass_generators(): @@ -107,6 +108,29 @@ def test_testfactory_reuses_existing_subclass_var(): assert reused_var == var_circle +def test_testfactory_does_not_retry_failed_type_exponentially(): + """A type that no generator can build is given up on after linearly many attempts. + + Every generator of ``Expression`` needs an ``Expression`` receiver. Without + remembering failed creations, each depth level multiplied the attempts by the + number of generators. + """ + config.configuration.test_creation.max_recursion = 6 + cluster = generate_test_cluster("tests.fixtures.examples.abstract_self_generators") + expression_type = cluster.type_system.convert_type_hint(Expression) + factory = TestFactory(cluster) + + with mock.patch.object(factory, "_emit_accessible", wraps=factory._emit_accessible) as emit: + var_name, cursor = factory._create_or_reuse_var( + TestCase(), expression_type, Expression, 0, 0 + ) + + assert (var_name, cursor) == (None, 0) + num_generators = len(cluster.get_generators_for(expression_type)) + assert emit.call_count <= num_generators * 6 + assert factory._failed_creation_depths is None + + def test_random_generator_provider_subtypes(): """Verify that RandomGeneratorProvider also resolves subtypes for abstract classes.""" config.configuration.generator_selection.generator_selection_algorithm = ( diff --git a/tests/fixtures/examples/abstract_self_generators.py b/tests/fixtures/examples/abstract_self_generators.py new file mode 100644 index 000000000..cfd898ff5 --- /dev/null +++ b/tests/fixtures/examples/abstract_self_generators.py @@ -0,0 +1,47 @@ +# This file is part of Pynguin. +# +# SPDX-FileCopyrightText: 2019–2026 Pynguin Contributors +# +# SPDX-License-Identifier: MIT +# +"""Fixture with an abstract class that only its own abstract methods can generate. + +Mirrors the shape of the ``numbers`` ABCs: no concrete subclass exists, so every +generator of ``Expression`` needs an ``Expression`` receiver. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class Expression(ABC): + """Abstract expression without any concrete subclass.""" + + @abstractmethod + def simplify(self) -> Expression: + """Simplify the expression.""" + + @abstractmethod + def negate(self) -> Expression: + """Negate the expression.""" + + @abstractmethod + def expand(self) -> Expression: + """Expand the expression.""" + + @abstractmethod + def derive(self) -> Expression: + """Derive the expression.""" + + +def evaluate(expression: Expression) -> int: + """Evaluate the expression. + + Args: + expression: The expression to evaluate. + + Returns: + The value of the expression. + """ + return 0 if expression is None else 1